The Token Nobody Gets to See
Here's the design constraint the whole auth system flows from: the backend demands a bearer token on every request — and that token must never exist in a browser. Not in a cookie, not in localStorage, not in JavaScript memory. An operator's laptop on incident WiFi is not a place to keep credentials that control physical cameras.
So the app runs an unusual, elegant machine: sign-in proves itself to Entra with a certificate-signed assertion instead of a client secret; the resulting tokens are written to Cosmos DB and only ever a pointer to them travels in the session cookie; and an edge middleware quietly attaches the real bearer token to each request server-side, where the browser can't see it. This deep dive walks that machine end to end — everything verified against current source.
First, take the gatekeeper's chair — five requests walk in, and you name the layer that decides each one's fate:
An anonymous visitor pastes the app URL into a browser on incident WiFi.
Overview
The app authenticates users against Entra External ID (alertcalifornia.ciamlogin.com)
using NextAuth v4 with the azure-ad provider. Three things make this setup unusual
enough to be worth a dedicated page:
- There is no client secret. The token exchange is authenticated with a certificate-signed client assertion (PS256), built at request time from a PEM private key pulled out of Key Vault.
- Access tokens never enter the browser. They are written to Cosmos DB, not the session cookie. The cookie only carries stable identifiers. Anything server-side that needs a real bearer token looks it up by account id.
- The bearer token is injected by edge middleware, not by the API routes themselves.
proxy.tsresolves a fresh token per request through a loopback HTTP call — for a very specific reason involving Azure Front Door's WAF (see below).
| Concern | Where it lives |
|---|---|
| NextAuth provider + callbacks | pages/api/auth/[...nextauth].ts |
| Certificate / assertion / token-endpoint mechanics | adapters/entraTokenClient.ts |
| Cosmos-backed token store + refresh locking | adapters/cosmosAdapter.ts |
| Session cookie name & flags | lib/sessionCookieName.ts |
| Per-request token resolution endpoint | pages/api/auth/renew.ts |
| Edge middleware that injects the bearer token | proxy.ts (repo root) |
| Client-side session wiring | app/layout.tsx |
| Client-side auth gate / redirect behavior | components/atoms/AuthGuard/AuthGuard.tsx |
| Sign-out + soft token revocation | pages/api/logout.ts |
The flow, end to end
Sign-in: certificate-based client assertion
The provider block in pages/api/auth/[...nextauth].ts is deliberately configured with an
empty clientSecret and:
client: {
token_endpoint_auth_method: 'none',
token_endpoint_auth_signing_alg: 'PS256',
},
token_endpoint_auth_method: 'none' stops the openid-client library from trying to send
client credentials, because the code-for-token exchange is handled by a custom
token.request() implementation instead. That handler:
getPem()— loads the PKCS#8 private key. In production it comes from Key Vault viagetSecretFromKeyVault(cacheKey), wherecacheKeyisAZURE_KEY_VAULT_CERT_NAME(defaultalertca-next-auth-dev-pem); outside production it is read off disk fromkeys/<env>/private_key.pem. The result is memoized inmemory-cache, but the lookup is fetch-if-missing rather than assume-cached — with horizontal scaling, an instance that has only ever served a token refresh and never a sign-in would otherwise find an empty cache.getJwtPayload(clientId, tokenEndpoint)— builds the assertion claims:audis the token endpoint,issandsubare both the client id, plus a randomjtiand a 10-minuteexp.createClientAssertion(payload, pem, thumbprint)— signs withPS256viajose, and sets the certificate thumbprint (AUTH_MICROSOFT_ENTRA_THUMBPRINT) as thex5theader so Entra knows which registered certificate to validate against.fetchTokens(tokenEndpoint, params, assertion, redirectUri)— POSTs the form body withclient_assertionandclient_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
The requested scope is
openid profile email offline_access api://{NEXT_PUBLIC_ENTRA_API_APP_ID}/user_impersonation —
offline_access is what makes the refresh token available, and the user_impersonation
scope is what makes the resulting access token accepted by Frontend.API.
The same assertion machinery is reused by refreshAccessToken() (refresh-token grant) and
by acquireCondorToken(), which runs a two-step On-Behalf-Of exchange to get a
CONDOR-scoped token.
Token storage: Cosmos, not the cookie
The signIn callback calls upsertAccountTokens(provider, providerAccountId, user.id, tokens)
from adapters/cosmosAdapter.ts. This writes one document per user — an AccountDoc
keyed by accountDocId(provider, providerAccountId), which produces ids of the form
account:azure-ad:<oid>. It holds access_token, refresh_token, expires_at,
id_token, roles, loginHint, and a lockedUntil field used as a refresh lock.
Two reasons this is not "just use the JWT cookie":
- Size and exposure. An Entra access token in the cookie means the token is shipped to the browser on every request and back on every request. Keeping it in Cosmos means the browser never holds one.
- Cross-instance coordination. The app runs horizontally scaled. If each instance kept
its own copy of the refresh chain, two instances refreshing concurrently would race and
one would invalidate the other's refresh token. Cosmos gives them a single shared
record, and
conditionalReplaceDoc(body, expectedEtag)turns an ETag mismatch into a "you lost the race, back off" signal rather than an error.getValidToken()layers same-instance request coalescing on top, so a page polling a dozen cameras at once produces one logical refresh rather than a dozen.
Note that this is not a NextAuth database adapter. Identity still lives in Entra and
the JWT cookie is still the session; Cosmos only holds the token state that needs to be
shared between instances. The doc carries a Cosmos ttl recomputed by remainingTtl() so
it always expires at createdAt + ACCOUNT_TTL_SECONDS (default 12h) no matter how many
times it is rewritten by refreshes — Cosmos TTL otherwise counts from the last write.
When Cosmos is not configured (AZURE_COSMOS_ENDPOINT / AZURE_COSMOS_DATABASE_NAME /
AZURE_COSMOS_AUTH_CONTAINER_NAME unset), the adapter silently falls back to an in-memory
Map so local dev can sign in without Cosmos RBAC. That switch keys off the env vars, not
NODE_ENV, so a local npm run dev can be pointed at a real Cosmos account just by
setting the three variables.
What the session cookie actually contains
The cookie name comes from lib/sessionCookieName.ts:
export const SESSION_COOKIE_NAME =
process.env.NODE_ENV === 'production'
? '__Secure-next-auth.session-token'
: 'next-auth.session-token';
It is set httpOnly, sameSite: 'lax', path: '/', secure: true. (secure: true
applies in every environment, including dev — local development therefore needs an
https-origin or a browser that still accepts it on localhost.)
On first sign-in the jwt callback writes only stable identifiers into it:
| Claim | Source |
|---|---|
provider | account.provider |
providerAccountId | account.providerAccountId |
accountId | accountDocId(provider, providerAccountId) — the Cosmos lookup key |
roles | extractRoles(account.access_token) — decoded roles claim |
loginHint | extractLoginHint(account.id_token) |
oid | user.id (Entra object id, from the provider's profile() mapping) |
createdAt | Math.floor(Date.now() / 1000) |
No access token, no refresh token, no id token.
proxy.ts — the real middleware
In Next.js 16 the root middleware file is named proxy.ts (this is the same slot that was
middleware.ts in earlier versions). It exports a plain
async function middleware(req: NextRequest) — it does not use
withAuth from next-auth/middleware.
For every request matching config.matcher it:
- Skips anything that is not under
/api/, and explicitly skips/api/auth/*(otherwise the sign-in flow would need a session to obtain a session). - Reads
SESSION_COOKIE_NAMEoff the request. No cookie means an immediate401with body{"error":"Unauthorized"}. - Calls
resolveSession(sessionToken)— see the loopback section below. - On failure, returns
401with body{"error":"SESSION_EXPIRED"}and anX-Auth-Error: SESSION_EXPIREDresponse header, which is what client code keys off to distinguish an expired session from a generic auth failure. - On success, clones the request headers and sets
Authorization: Bearer <accessToken>andx-user-id: <oid>, then continues viaNextResponse.next({ request: { headers: requestHeaders } }).
The downstream API route reads that header straight through — for example
pages/api/lease.ts passes Authorization: req.headers.authorization ?? '' into
callBackend, and lib/withApiHandler.ts reads x-user-id for request-scoped logging
and tracing (falling back to getServerSession only when the header is absent, which is
the case for routes outside the matcher).
Debugging checklist
- Every call to one route returns 401, others are fine — check whether that path is in
the
proxy.tsmatcher. X-Auth-Error: SESSION_EXPIREDon responses —renewcould not produce a token: theAccountDocis gone (TTL elapsed, or logout deleted it) or the refresh chain is dead. Signing in again heals it, becauseupsertAccountTokensoverwrites the tokens on a returning sign-in.- Sign-in fails at the token exchange — look for
Token request errorfrom[/Auth], thenToken endpoint request failedfrom[EntraTokenClient]. Usual causes are a stale thumbprint, a rotated certificate, or a Key Vault access failure on a cold instance. - Session drops early — compare
token.createdAt + 12hagainst the clock before blaming the refresh logic; the hard cap fires independently of activity. - Local dev — with the Cosmos env vars unset you are on the in-memory store, so tokens
do not survive a server restart and each
next devworker has its own copy.
Related
deep-dives/signalr-realtime-architecture— the SignalR connection authenticates separately from the/api/*path described here.security/archive-reverse-proxy— the/api/archive/:path*wildcard in the matcher above is what makes that proxy an authenticated route.