Auth
How the WorkOS handshake works across admin and backend.
WorkOS AuthKit handles sign-in for the admin app. The backend doesn't talk to WorkOS at all at runtime — it just verifies tokens against the public JWKS.
Flow
Browser hits a protected route
apps/admin/src/middleware.ts runs authkitMiddleware with
middlewareAuth.enabled = true. No session cookie → 307 to
https://api.workos.com/user_management/authorize?…&client_id=…&redirect_uri=http://localhost:3000/callback&….
(Why not proxy.ts? Next 16 hard-codes the new proxy.ts file convention to
the Node runtime, but OpenNext for Cloudflare can only host Edge middleware.
The deprecated middleware.ts filename still accepts runtime: "experimental-edge" in Next 16, so we use that.)
User signs in at WorkOS
Hosted UI. WorkOS redirects back to /callback?code=…&state=….
Callback exchanges the code
apps/admin/src/app/callback/route.ts is just export const GET = handleAuth().
The package exchanges the code at https://api.workos.com/sso/token using
WORKOS_API_KEY, gets back an access token + refresh token + user, seals
them into a cookie with WORKOS_COOKIE_PASSWORD (iron-session), and 307s
to the originally-requested path.
Server component reads the session
const { user, accessToken } = await withAuth({ ensureSignedIn: true });AuthKit unseals the cookie, refreshes the access token if it's near expiry,
and returns a typed user plus a short-lived JWT.
Admin calls backend with Bearer token
fetch(`${BACKEND_URL}/admin/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});Backend verifies against JWKS
The backend declares an Authentication middleware tag in
features/admin/admin.auth.ts and binds it to a bearer scheme.
The implementation in admin.handlers.ts calls verifyWorkOSAccessToken(token),
which uses jose:
const jwks = createRemoteJWKSet(new URL(`https://api.workos.com/sso/jwks/${WORKOS_CLIENT_ID}`));
const { payload } = await jwtVerify(token, jwks);
return payload;The verified payload becomes the CurrentUser service for the duration
of the request. Endpoint handlers read it via yield* CurrentUser.
Failure short-circuits to a 401 Unauthorized. JWKS is cached after the
first call — no round-trip to WorkOS per request.
What lives where
| Concern | Owner | File |
|---|---|---|
| Session cookie | Admin | sealed via iron-session in handleAuth() |
| Access token issuance | WorkOS | code exchange at /sso/token |
| Access token verification | Backend | verifyWorkOSAccessToken in lib/auth/workos.ts |
| Per-request user object | Admin | withAuth({ ensureSignedIn: true }) |
| Per-request user object | Backend | yield* CurrentUser inside an Authentication-guarded group |
The two clients
- Admin uses
WORKOS_API_KEY(server-only, code-exchange auth) andWORKOS_CLIENT_ID(public-ish, identifies the application). - Backend uses only
WORKOS_CLIENT_ID— that's enough to fetch the JWKS and verify signatures. No API key needed unless you start calling the WorkOS REST API directly.
The two WORKOS_CLIENT_ID values must match. That's the load-bearing
piece — if they drift, every request to /admin/me returns 401.
WorkOS access tokens are short-lived (~5 min). AuthKit's session cookie carries a longer-lived
refresh token and exchanges it transparently when withAuth() is called. You don't have to think
about token expiry in the admin code.
Adding a new protected route
Backend — declare an HttpApiGroup and chain .middleware(Authentication):
// features/<thing>/http/<thing>.api.ts
export class ThingApi extends HttpApiGroup.make("thing")
.add(HttpApiEndpoint.get("get", "/things/:id").setPath(IdParam).addSuccess(Thing))
.middleware(Authentication) {}Then add it to Api in src/api.ts and provide the handlers in
server.ts. Inside a handler, read the verified user via
yield* CurrentUser.
Admin — already protected by middleware.ts. Call withAuth() and
forward accessToken:
const { accessToken } = await withAuth({ ensureSignedIn: true });