bao

Service-to-service auth (JWKS)

Verify auth tokens in your backend services and Cloudflare Workers without calling the auth-service on every request.

When JWKS is enabled, your bao auth-service issues a signed JWT on every sign-in alongside the session cookie. Backend services can verify that JWT locally using the public keys exposed at the JWKS endpoint — no round-trip to the auth-service required per request.

This is the right approach when:

  • You have multiple services or Workers that need to verify identity independently.
  • You want to avoid the latency of calling the auth-service on every authenticated request.
  • You’re building an API that accepts Authorization: Bearer <token> headers.

JWKS is included in the Pro plan.


Enable JWKS in bao.config.json

{
  "auth": {
    "plugins": {
      "jwks": {
        "enabled": true
      }
    }
  }
}

Push the config change and re-run the deploy workflow. The auth-service will begin issuing JWTs on sign-in and expose a public key set at /.well-known/jwks.json.


Client setup

The browser-side client is identical to the session-based setup. No changes needed — the JWT is issued automatically alongside the session cookie.

// lib/auth-client.ts
import { createAuthClient } from "better-auth/client";

export const authClient = createAuthClient({
  baseURL: "https://my-app-production.workers.dev",
});

Sign in as normal — the auth-service sets both a session cookie and a JWT cookie in the response.


Verify a JWT in your backend

Install jose — a zero-dependency JWT library that works in any JS runtime including Cloudflare Workers.

npm install jose

Fetch the public keys from your auth-service once and cache them, then verify incoming tokens:

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://my-app-production.workers.dev/.well-known/jwks.json"),
);

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS);
  return payload; // contains userId, email, etc.
}

createRemoteJWKSet handles key caching and rotation automatically.


Cloudflare Worker example

Read the JWT from the Authorization header or from the cookie set by the auth-service:

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://my-app-production.workers.dev/.well-known/jwks.json"),
);

export default {
  async fetch(request: Request): Promise<Response> {
    const auth = request.headers.get("Authorization");
    const token = auth?.replace("Bearer ", "");

    if (!token) {
      return new Response("Unauthorized", { status: 401 });
    }

    try {
      const { payload } = await jwtVerify(token, JWKS);
      // payload.sub is the user ID
      return new Response(`Hello ${payload.sub}`);
    } catch {
      return new Response("Invalid token", { status: 401 });
    }
  },
};

Next.js example

Verify the token in a Route Handler or middleware without calling the auth-service:

// app/api/protected/route.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
import { cookies } from "next/headers";

const JWKS = createRemoteJWKSet(
  new URL(`${process.env.AUTH_SERVICE_URL}/.well-known/jwks.json`),
);

export async function GET() {
  const token = (await cookies()).get("better-auth.jwt")?.value;

  if (!token) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const { payload } = await jwtVerify(token, JWKS);
    return Response.json({ userId: payload.sub });
  } catch {
    return Response.json({ error: "Invalid token" }, { status: 401 });
  }
}

Set AUTH_SERVICE_URL in .env.local to your worker URL.


JWKS endpoint

The public key set is served at:

https://{your-worker-url}/.well-known/jwks.json

Keys are rotated automatically by your bao auth-service. createRemoteJWKSet from jose handles re-fetching when it encounters a key ID not present in its local cache.