Skip to main content
This document outlines the proposed architecture for replacing session-based round-trip authentication with JWT cookies in the k0rdent platform. The goal is to eliminate the per-request round-trip to the auth service, reducing latency and improving scalability.

Current Architecture (Problem)

Currently, every API request triggers a round-trip from arc-api/atlas-api to the auth service, which queries the database. This adds latency and creates a coupling between API services and the auth service. Every API request triggers a round-trip from arc-api/atlas-api to the auth service, which queries the database. This adds latency and creates a coupling between API services and the auth service.

Target Architecture

JWT Payload Structure

The JWT uses a k0r namespace for custom claims, following the structure you specified:
{
  "iss": "<AUTH_BASE_URL>",
  "sub": "<user.id>",
  "aud": "k0rdent-api",
  "exp": 1719500000,
  "iat": 1719496400,
  "name": "Jerry Smith",
  "email": "jerry@acme.com",
  "email_verified": true,
  "k0r": {
    "principal_type": "employee",
    "orgs": {
      "org-abc123": ["owner"],
      "org-def456": ["member"]
    },
    "projects": {
      "proj-789": { "org": "org-abc123", "roles": ["member"] },
      "proj-012": { "org": "org-abc123", "roles": ["admin"] }
    }
  }
}

Changes Required

1. Database: Add JWKS Table

The Better Auth JWT plugin requires a jwks table to store key pairs. Add it to the Drizzle schema for type safety, then run the Better Auth migration. File: packages/db/src/schema/auth.ts
  • Add a jwks table definition (id, publicKey, privateKey, createdAt, expiresAt)
  • Register in the drizzle adapter schema in auth.ts
Migration: Run pnpm dlx @better-auth/cli migrate from the auth service directory.

2. Auth Service: Configure JWT Plugin with Rich Payload

File: services/auth/src/auth.ts Update the jwt() plugin configuration:
jwt({
  jwt: {
    issuer: process.env.AUTH_BASE_URL ?? 'http://localhost:8000',
    audience: 'k0rdent-api',
    expirationTime: '15m',
    definePayload: async ({ user }) => {
      // Query org memberships and project memberships from DB
      // Build the k0r claims namespace
      return { /* rich payload */ };
    }
  }
})
The definePayload function needs to:
  • Query organizationMembers joined with organizations for the user
  • Query projectMembers joined with projects for the user
  • Build the k0r namespace with org and project role maps
  • Determine principal_type from user role
The JWT plugin returns tokens via headers/endpoints but does not set cookies. We need to add a mechanism to set the JWT as a cookie. Approach: Add Hono middleware to the auth service that intercepts responses from session-creating endpoints (sign-in, OAuth callback, session refresh). When a session cookie is being set, also mint a JWT and set it as a k0r-jwt cookie. File: services/auth/src/lib/jwt-cookie.ts (new)
  • Hono middleware that runs after Better Auth processes requests
  • Detects when better-auth.session_token cookie is set in the response
  • Calls auth.api.getToken() (internal JWT plugin API) to mint a JWT
  • Appends a Set-Cookie: k0r-jwt=<token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900 header
File: services/auth/src/index.ts
  • Apply the JWT cookie middleware to the Better Auth catch-all handler
Also handle logout: When the session is destroyed (DELETE /session), clear the k0r-jwt cookie by setting it with Max-Age=0.

4. Auth Service: Custom Session Endpoint Enhancement

File: services/auth/src/routes/v1/session.ts
  • In the GET /session handler, after returning the enriched session, also set/refresh the k0r-jwt cookie so it stays in sync with session refreshes
  • In the DELETE /session handler, clear the k0r-jwt cookie
  • In the POST /session (login) handler, the middleware from step 3 handles this

5. API Services: JWT Verification Middleware

Create a shared JWT verification utility and update both API services. File: packages/api/src/jwt.ts (new)
  • createJwtMiddleware(options) factory function for Hono middleware
  • Uses jose library (createRemoteJWKSet, jwtVerify) to verify JWTs
  • Reads JWT from k0r-jwt cookie
  • Caches the JWKS (remote JWKS set handles this automatically)
  • Extracts and types the k0r claims namespace
  • Exports TypeScript types for the JWT payload (K0rJwtPayload, K0rClaims)
File: apps/arc-api/src/lib/middleware/session.ts
  • Add JWT verification as the primary auth path
  • Extract user, org, and project data from JWT claims
  • Fall back to current cookie-forwarding session validation if no JWT cookie (migration period)
  • Set context variables (user, session, currentOrganization, orgRole) from JWT payload
File: apps/atlas-api/src/lib/middleware.ts
  • Same pattern as arc-api
  • Extract userRole from JWT claims (user.role or k0r.principal_type)
Dependencies: Add jose to packages/api/package.json and the catalog. Standard cookies have a ~4KB limit. A JWT with many org/project memberships could exceed this. Mitigation options:
  • Short-term: Most users have few orgs/projects; monitor payload size
  • Long-term: If payload grows too large, store a JWT reference in the cookie and look up claims from a cache, or split into essential claims (current org only) + a separate endpoint for full claims
For now, the implementation will include a size check and log a warning if the JWT cookie approaches 4KB. No changes needed for the Next.js Server Actions — they already forward all cookies (including k0r-jwt) via getCookieHeader() in apps/arc/src/lib/auth.utils.ts. The JWT cookie will be automatically forwarded alongside the session cookie. The auth client plugins (jwtClient()) are optional and only needed if the frontend wants to explicitly request JWT tokens. Since the JWT is set as a cookie by the auth service, no client-side plugin changes are required.

Key Design Decisions

  • JWT as cookie (not Bearer): Cookies are automatically forwarded by the browser and Next.js Server Actions, requiring zero changes to the API call pattern
  • 15-minute JWT expiry: Short enough to limit stale claims, long enough to avoid constant refresh overhead. Refreshed on every getSession call (which happens on page loads via cookieCache)
  • Fallback to session validation: During migration, if no JWT cookie is present, the API services fall back to the current cookie-forwarding approach
  • **jose for verification**: Standard, well-maintained library for JWT/JWKS operations. Already transitively available (in pnpm-lock.yaml via better-auth)
  • JWKS caching: createRemoteJWKSet from jose handles caching and key rotation automatically