Darna Docs

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

apps/backend/src/middleware/admin.ts reads the header. Using jose:

const jwks = createRemoteJWKSet(
  new URL(`https://api.workos.com/sso/jwks/${WORKOS_CLIENT_ID}`),
)
const { payload } = await jwtVerify(token, jwks)
c.set("user", payload)

Cached after the first call. No round-trip to WorkOS per request.

What lives where

ConcernOwnerFile
Session cookieAdminsealed via iron-session in handleAuth()
Access token issuanceWorkOScode exchange at /sso/token
Access token verificationBackendverifyWorkOSAccessToken in lib/auth/workos.ts
Per-request user objectAdminwithAuth({ ensureSignedIn: true })
Per-request user objectBackendc.get("user") after adminMiddleware

The two clients

  • Admin uses WORKOS_API_KEY (server-only, code-exchange auth) and WORKOS_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 — apply adminMiddleware to a sub-app or per-route:

new Hono()
  .use("*", adminMiddleware)
  .get("/me", (c) => c.json(c.get("user")))

Admin — already protected by middleware.ts. Just call withAuth() and use accessToken:

const { accessToken } = await withAuth({ ensureSignedIn: true })

On this page