Skip to main content

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:

🛂 Gatekeeperrequest 1/5 · score 0

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:

  1. 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.
  2. 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.
  3. The bearer token is injected by edge middleware, not by the API routes themselves. proxy.ts resolves a fresh token per request through a loopback HTTP call — for a very specific reason involving Azure Front Door's WAF (see below).
ConcernWhere it lives
NextAuth provider + callbackspages/api/auth/[...nextauth].ts
Certificate / assertion / token-endpoint mechanicsadapters/entraTokenClient.ts
Cosmos-backed token store + refresh lockingadapters/cosmosAdapter.ts
Session cookie name & flagslib/sessionCookieName.ts
Per-request token resolution endpointpages/api/auth/renew.ts
Edge middleware that injects the bearer tokenproxy.ts (repo root)
Client-side session wiringapp/layout.tsx
Client-side auth gate / redirect behaviorcomponents/atoms/AuthGuard/AuthGuard.tsx
Sign-out + soft token revocationpages/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:

  1. getPem() — loads the PKCS#8 private key. In production it comes from Key Vault via getSecretFromKeyVault(cacheKey), where cacheKey is AZURE_KEY_VAULT_CERT_NAME (default alertca-next-auth-dev-pem); outside production it is read off disk from keys/<env>/private_key.pem. The result is memoized in memory-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.
  2. getJwtPayload(clientId, tokenEndpoint) — builds the assertion claims: aud is the token endpoint, iss and sub are both the client id, plus a random jti and a 10-minute exp.
  3. createClientAssertion(payload, pem, thumbprint) — signs with PS256 via jose, and sets the certificate thumbprint (AUTH_MICROSOFT_ENTRA_THUMBPRINT) as the x5t header so Entra knows which registered certificate to validate against.
  4. fetchTokens(tokenEndpoint, params, assertion, redirectUri) — POSTs the form body with client_assertion and client_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_impersonationoffline_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.

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.

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:

ClaimSource
provideraccount.provider
providerAccountIdaccount.providerAccountId
accountIdaccountDocId(provider, providerAccountId) — the Cosmos lookup key
rolesextractRoles(account.access_token) — decoded roles claim
loginHintextractLoginHint(account.id_token)
oiduser.id (Entra object id, from the provider's profile() mapping)
createdAtMath.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:

  1. 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).
  2. Reads SESSION_COOKIE_NAME off the request. No cookie means an immediate 401 with body {"error":"Unauthorized"}.
  3. Calls resolveSession(sessionToken) — see the loopback section below.
  4. On failure, returns 401 with body {"error":"SESSION_EXPIRED"} and an X-Auth-Error: SESSION_EXPIRED response header, which is what client code keys off to distinguish an expired session from a generic auth failure.
  5. On success, clones the request headers and sets Authorization: Bearer <accessToken> and x-user-id: <oid>, then continues via NextResponse.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.ts matcher.
  • X-Auth-Error: SESSION_EXPIRED on responsesrenew could not produce a token: the AccountDoc is gone (TTL elapsed, or logout deleted it) or the refresh chain is dead. Signing in again heals it, because upsertAccountTokens overwrites the tokens on a returning sign-in.
  • Sign-in fails at the token exchange — look for Token request error from [/Auth], then Token endpoint request failed from [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 + 12h against 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 dev worker has its own copy.