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.authcookie - CSRF
XSRF-TOKENcookie, sent back asX-XSRF-TOKEN- Company
currentCompanyStore, sent asX-Company-Id- Permissions
GET /api/v1/me/access, checked byCan
Sign-in from the browser’s view
Section titled “Sign-in from the browser’s view”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.
Login page navigates to /api/v1/loginappointme.auth, then redirects backisAuthenticated: true, plus the companies| 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:
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]);On the server
Section titled “On the server”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:
.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.
Demo login and 401s
Section titled “Demo login and 401s”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.
Antiforgery: the XSRF cookie and header
Section titled “Antiforgery: the XSRF cookie and header”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 |
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 current company
Section titled “The current company”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 |
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.
Onboarding and invitations
Section titled “Onboarding and invitations”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.
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.
Permission-gated UI
Section titled “Permission-gated UI”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.
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:
<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:
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
Section titled “Logout”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.
export const logout = () => { const form = document.createElement('form'); form.method = 'post'; form.action = '/api/v1/logout'; document.body.appendChild(form); form.submit();}; 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.
Gotchas
Section titled “Gotchas”| 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.