JWKS and local verification

Start with what it is not for

Your app Worker does not verify access tokens against JWKS. Its session middleware asks Better Auth to resolve the credential — a database lookup — and fills c.var.auth from the result. That is one D1 read on requests that carry a credential, and none on requests that do not.

Which of the two to reach for is a verification strategy rather than a preference, and the trade is stated there in full.

The JWKS endpoint exists for the case where that is the wrong shape: a separate service that must accept your users’ tokens without reaching your auth database. A second Worker, a job runner, an internal API on another origin. It fetches the public keys once, caches them, and verifies signatures locally.

What is published

The route, its shape and the rest of this capability’s surface are in the reference.

GET /auth/jwks is one of the endpoints Better Auth’s jwt plugin owns, served under the auth catch-all. It answers with the public halves of the keys in pithy_auth_jwks:

FieldWhat a verifier does with it
kidMatches the token’s header, so it picks a key instead of trying all of them
algEdDSA or RS256 — published so nothing has to guess
crvEd25519, P-256, or absent for RSA, which has no curve

alg being on the wire is a security property, not a convenience. A verifier that infers the algorithm from the key material, or worse from the token’s own header alone, is one confused-deputy step from accepting something it should not.

A key row minted before Better Auth 1.7 has no alg and no crv stored; the plugin falls back to EdDSA, which is in fact what those keys are.

The private half never leaves

Private keys are encrypted at rest in the same table, under the Better Auth signing secret — which the kit sources from auth-session-secret in the secret registry, not from an environment literal. That one secret does three jobs: it signs session cookies, it backs the bearer HMAC, and it encrypts these keys.

Which is why rotating it is not a routine operation. Better Auth accepts a version set so a rotation keeps prior signatures valid, but a rotation that loses the old value loses the ability to decrypt every stored private key.

Rotation, and why old keys stay published

A retired keypair keeps its public half in the JWKS response through a grace window, tracked by expiresAt on the row.

A token in flight was signed by whatever key was current when it was minted. Pulling that key out of the published set the instant it retires invalidates every access token issued in the last few minutes — for no benefit, since the key is public and its private half is already out of use. The grace window is the difference between a rotation nobody notices and a rotation that logs everyone out.

The comment that says otherwise

packages/auth/src/instance/plugins.ts describes the jwt plugin as minting “the JWKS the control-plane seam verifies against.” That is not what the control plane does.

Control-plane calls are verified with Ed25519 over a compact JWS whose keys live in pithy_controlplane_connections — a public key registered when a management client connected, looked up by kid on the connection, checked for revocation and window. It never reads pithy_auth_jwks, and a project with no auth capability composed still verifies control-plane calls perfectly well.

The two mechanisms genuinely resemble each other. They are not the same one.

A checklist for the resource server

  1. Fetch /auth/jwks and cache it, with a refresh on an unknown kid rather than on a timer alone.
  2. Pin the issuer and the audience your tokens carry. A signature check alone answers was this signed by that key, not was this meant for me.
  3. Accept only the algorithms in the published set. Never the one the token asks for.
  4. Remember what you cannot learn this way. Local verification cannot see a revoked session — that is the trade you took to avoid the round-trip. Keep access-token lifetimes short enough that the gap is one you can live with.
ESC