Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20269 min read

Auth & tenancy in the SPA

How the AppointMe React SPA signs users in through the .NET API's cookie session, sends the antiforgery token, picks the current company and hides UI the user has no permission for.

Session
An HttpOnly appointme.auth cookie
CSRF
XSRF-TOKEN cookie, sent back as X-XSRF-TOKEN
Company
currentCompanyStore, sent as X-Company-Id
Permissions
GET /api/v1/me/access, checked by Can

The SPA never handles a token. It sends the browser to the API, the API runs OpenID Connect with the identity provider, and the browser comes back holding an HttpOnly cookie.

/auth/loginthe Login page navigates to /api/v1/login
LoginEndpointan OIDC challenge to Keycloak or Entra
/signin-oidcthe API sets appointme.auth, then redirects back
GET /api/v1/meisAuthenticated: true, plus the companies
Figure 1. A full-page round trip. The SPA only ever sees the result of /api/v1/me.
Step Where What happens
Find out who’s there AppShell → CurrentUserProvider GET /api/v1/me allows anonymous callers. isAuthenticated: false sends the router to /auth/login.
Leave the SPA app/auth/login/login.tsx A full-page navigation to /api/v1/login with returnUrl and an optional provider
Challenge Identity/AppointMe.Identity/Login/LoginEndpoint.cs Accepts only a relative returnUrl. Under Keycloak, provider becomes kc_idp_hint, which the signup page’s Google button uses.
Callback /signin-oidc, proxied by Vite in development The API stores only the id_token in the cookie and redirects to Frontend:BaseUrl plus returnUrl
Back in the SPA app/router/app-shell.tsx /me now returns the user, and the router picks the app, invitations or onboarding

The Login page does nothing but hand over to the API:

src/AppointMe.Frontend/src/app/auth/login/login.tsxView source ↗
useEffect(() => {
const returnUrl = params.get('returnUrl') ?? '/';
const provider = params.get('provider');
const query = new URLSearchParams();
query.set('returnUrl', returnUrl);
if (provider) {
query.set('provider', provider);
}
window.location.href = `/api/v1/login?${query.toString()}`;
}, [params]);

The API’s default scheme, Hybrid, uses the JWT bearer handler when a request has an Authorization: Bearer header and the cookie otherwise. The SPA always takes the cookie path:

src/AppointMe.Api/Authentication/AuthenticationExtensions.csView source ↗
.AddPolicyScheme(HybridAuthenticationDefaults.AuthenticationScheme, "Cookie or Bearer", options =>
{
options.ForwardDefaultSelector = context => context.Request.HasBearerTokenHeader()
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = "appointme.auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
})

Authentication:Provider picks Keycloak (the default) or Entra External ID. When a handler needs the caller’s identity, HttpIdentityFactory turns the cookie’s claims into an AppointMe UserIdentity, registering the user the first time it sees them. How that identity becomes a per-company principal is on Identity & principal.

GET /api/v1/login/demo signs in the configured demo user without showing the identity provider’s login page. It works only when Demo:Enabled is true: appsettings.Development.json turns it on, appsettings.json leaves it off, and when it’s off the endpoint returns 404.

Under Keycloak the endpoint gets the demo user’s id_token through the password grant, signs in the cookie and redirects to Frontend:BaseUrl. The SPA has no button for it, so you open the URL directly.

The axios instance sends the browser to /auth/login whenever a response is 401. The API returns a bare 401 only to Bearer requests. A cookie request without a session gets the default challenge instead, which is a redirect to the identity provider, because DefaultChallengeScheme is OpenID Connect.

Every POST, PUT, PATCH or DELETE under /api without an Authorization: Bearer header needs an X-XSRF-TOKEN header, signed in or not. Axios adds it from a cookie the API sets on every GET.

Name Set by Script can read it Role
appointme.csrf cookie ASP.NET Core antiforgery No, HttpOnly The cookie half of the token pair
XSRF-TOKEN cookie AntiforgeryMiddleware, on every GET Yes The request token the SPA sends back
X-XSRF-TOKEN header axios (xsrfHeaderName, withXSRFToken) — Checked on every unsafe method
src/AppointMe.Api/Antiforgery/AntiforgeryMiddleware.csView source ↗
if (IsSafeMethod(context.Request.Method))
{
// Issued on every GET (not just once) because tokens are bound to the authenticated
// identity — the SPA's first GET after login/logout refreshes the pair automatically.
if (HttpMethods.IsGet(context.Request.Method))
{
IssueRequestTokenCookie(context);
}
await next(context);
return;
}
// …
// Bearer requests carry no ambient credential, so they cannot be forged cross-site;
// only cookie-backed requests need the token pair. Non-/api surfaces protect themselves
// (Hangfire ships its own antiforgery, the OIDC callback is covered by state/correlation).
private static bool RequiresValidation(HttpRequest request) =>
request.Path.StartsWithSegments(ApiPrefix) && !request.HasBearerTokenHeader();

A failed check returns 400 with the ProblemDetails title Antiforgery Validation Error. The SPA’s first request, GET /api/v1/me, already receives a fresh pair, so you don’t fetch a token yourself. Any call that goes through the generated hooks or axiosInstance sends it.

The selected company id lives in currentCompanyStore, backed by localStorage. The axios mutator reads it on every request, and switching company invalidates every query.

Piece Where Does
currentCompanyStore components/auth/current-company-store.ts get, set and subscribe over one id, saved as appointme:current-company-id
useCurrentCompanyId() same file Subscribes a component with useSyncExternalStore
CurrentCompanyProvider components/auth/current-company-context.tsx Checks the stored id against the companies from /me and falls back to the first. It renders nothing until a valid company is set.
useCurrentCompany() components/auth/use-current-company.ts { currentCompany: { companyId, companyName, timeZone }, setCurrentCompany }
CompanySelector app/layouts/header/company-selector.tsx The header dropdown that calls setCurrentCompany
src/AppointMe.Frontend/src/components/auth/current-company-context.tsxView source ↗
useEffect(() => {
if (companies?.length && !currentCompany) {
currentCompanyStore.set(companies[0].companyId);
}
}, [companies, currentCompany]);
const setCurrentCompany = useCallback(
async (companyId: string) => {
if (currentCompanyId === companyId) {
return;
}
currentCompanyStore.set(companyId);
await queryClient.invalidateQueries();
},
[currentCompanyId, queryClient],
);

Invalidating with no filter refetches every active query with the new X-Company-Id, including the permissions. The company’s timeZone drives date display through useTimeZone(). The header only states which company the caller wants: the API checks that the user belongs to it, as explained in Multi-tenancy.

A signed-in user with no company lands on /onboarding, or on /invitations if someone invited them. The main app opens once /me reports a membership.

src/AppointMe.Frontend/src/app/router/app-shell.tsxView source ↗
const useUserState = (): UserState => {
const currentUser = useCurrentUser();
if (!currentUser.isAuthenticated) {
return 'anonymous';
}
if (currentUser.hasMembership) {
return 'active';
}
if (currentUser.hasPendingInvitations) {
return 'has-invitations';
}
return 'needs-onboarding';
};
const stateRoutes: Record<UserState, string> = {
anonymous: '/auth/login',
'needs-onboarding': '/onboarding',
'has-invitations': '/invitations',
active: '/',
};
State Route What the page does
has-invitations /invitations Lists pending invitations. Accepting one calls POST /api/v1/invitations/{id}/accept, invalidates every query and goes to /.
needs-onboarding /onboarding OnboardingForm posts a company name and time zone to POST /api/v1/onboarding, invalidates /me and goes to /
active / Redirects to /appointments. CurrentCompanyProvider keeps the stored company if it’s valid, or selects the first.

A new user reaches onboarding from signup: /auth/signup posts to POST /api/v1/signup and shows /auth/verify-email/:email, and the link in the email returns through /auth/login?returnUrl=/onboarding. “Continue with Google” goes to /auth/login?provider=google&returnUrl=/onboarding. The user who creates a company gets the Owner and Staff roles.

UserAccessProvider loads the caller’s roles and permissions for the current company from GET /api/v1/me/access. Can and usePermission hide what they can’t use, and the API still checks every call.

src/AppointMe.Frontend/src/components/auth/use-permission.tsView source ↗
export const hasPermission = (granted: readonly string[], required: Permission | Permission[]): boolean =>
Array.isArray(required) ? required.some(permission => granted.includes(permission)) : granted.includes(required);
export const usePermission = (permission: Permission | Permission[]) => {
const { permissions } = useUserAccess();
return hasPermission(permissions, permission);
};

An array means “any of these”. <Can permission fallback> wraps the same check around children:

src/AppointMe.Frontend/src/app/customers/customers.tsxView source ↗
<Can permission='customers:create'>
<Button asChild size='sm'>
<Link to='/customers/new' aria-label='Add customer'>
<PlusIcon />
<span className='hidden sm:inline'>Add customer</span>
</Link>
</Button>
</Can>
Tool Use it for
<Can permission="…" fallback={…}> Hiding a button, a menu item or a whole page. Dashboard renders a “Dashboard access is limited” state as its fallback.
usePermission(…) A boolean for logic, such as enabled on a query
useUserAccess() The raw { companyId, roles, permissions }
permission on a NavItem Hiding a sidebar entry in app/layouts/sidebar/nav-data.ts
superAdminOnly on a NavItem Showing an entry only when /me says isSuperAdmin

The permission strings are typed. The API writes every permission code into the OpenAPI document, and orval turns them into a Permission union, so a typo in permission='customer:create' fails the type-check:

src/AppointMe.Frontend/src/api/client.schemas.tsView source ↗
export type Permission = (typeof Permission)[keyof typeof Permission];
export const Permission = {
'permissions:view': 'permissions:view',
// …
'customersstatistics:view': 'customers.statistics:view',
// …
} as const;

Routes aren’t gated. Hiding a sidebar entry doesn’t stop someone typing the URL, so gate the page itself with Can and a fallback. Where permissions come from is on Permissions.

Logout is a top-level form POST to /api/v1/logout, so the browser follows the identity provider’s end-session redirects as a normal navigation.

src/AppointMe.Frontend/src/lib/logout.tsView source ↗
export const logout = () => {
const form = document.createElement('form');
form.method = 'post';
form.action = '/api/v1/logout';
document.body.appendChild(form);
form.submit();
};
src/Identity/AppointMe.Identity/Logout/LogoutEndpoint.csView source ↗
private static async Task Logout(HttpContext context, IOptions<FrontendOptions> frontendOptions)
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
{
RedirectUri = frontendOptions.Value.BaseUrl.ToString()
});
}

The endpoint is RequireAuthorization() with the default policy, which asks only for a signed-in user and skips the fallback policy’s registered-user check. It clears the cookie, then signs out of the OIDC scheme, which redirects to the provider’s end-session endpoint and back to Frontend:BaseUrl.

The SPA doesn’t clear currentCompanyStore. The next user in the same browser still gets their own first company, because CurrentCompanyProvider ignores an id they don’t belong to.

Symptom Cause Fix
“Log out” answers 400 Antiforgery Validation Error lib/logout.ts submits a plain form with neither the X-XSRF-TOKEN header nor a __RequestVerificationToken field, and AntiforgeryMiddleware checks every non-Bearer POST under /api ASP.NET Core also accepts the token as a __RequestVerificationToken form field. Add a hidden input with the XSRF-TOKEN cookie’s value to the form.
A fetch you wrote fails with 400 Antiforgery Validation Error It bypassed axios, so it sent no X-XSRF-TOKEN Use a generated hook or axiosInstance.
A hidden page still opens from its URL Can and nav permission only hide UI Wrap the page in Can with a fallback. The API still returns 403.
A 403 or 404 shows no toast The global toast covers only network errors and 5xx Handle 4xx where you call the hook, for example isError and ErrorWidget.
A list shows the previous company’s rows for a moment after a switch Query keys don’t include the company, and invalidateQueries() keeps cached data while it refetches Use queryClient.resetQueries() in setCurrentCompany if pages should show their loading state instead.
Why a cookie session instead of tokens in the browser?2 min · optional

With an HttpOnly cookie, no script in the page can read the session, so an XSS bug can’t walk off with a token. The SPA has no token storage, no refresh logic and no Authorization header to manage. The API keeps only the id_token, inside the cookie, for logout’s id_token_hint.

The price of an ambient credential is CSRF, which is why the antiforgery pair exists. Bearer tokens stay available through the Hybrid scheme for clients that aren’t browsers, and those requests skip the antiforgery check because nothing sends them automatically.