shahriyar.dev
Back to blog
JWTJWKSBetter AuthNext.jsKey RotationAuthentication

Better Auth + JWKS JWT verification: a complete guide

·8 min read

JWT verification is a critical piece of modern authentication, but many implementations rely on hard-coded public keys stored inside the application code. This approach works until the signing key rotates – at which point all existing tokens become invalid, or worse, you have to coordinate a painful key update across multiple services. JSON Web Key Sets (JWKS) solve this by providing a dynamic, hosted endpoint where your auth server publishes its public keys. By fetching the correct key at runtime, your application can verify tokens without ever needing to know which key is currently active.

Better Auth, a minimalist authentication library built for Next.js, makes integrating JWKS-based JWT verification surprisingly straightforward. It gives you a clean interface to hook into the verification process and replace the default static key check with a live key set fetch. This guide walks you through every step – from understanding the JWKS workflow to implementing it inside Better Auth's verify callback with proper caching and rotation handling.

Why JWKS for JWT verification?

The core idea behind JWKS is to decouple your application from the specific signing keys used by the issuer. Instead of storing a single public key, you point to a URL that returns a JSON object containing an array of keys (each with a kid – key ID). The JWT itself includes a kid header, so your verification logic can match that ID to the correct key from the set.

  • Seamless key rotation – When the auth server rotates its keys, it simply adds the new key to the JWKS endpoint and optionally removes the old one. Your application automatically picks up the change on the next fetch.
  • Reduced blast radius – If a key is compromised, you can revoke it from the JWKS endpoint immediately. No need to redeploy your app.
  • Standards‑compliantJWKS is defined by RFC 7517 and used by major providers like Auth0, Firebase, and Okta. Adopting it makes your integration portable.
  • No hard‑coded secrets – Your application never stores a private key, only fetches public keys over HTTPS.

Prerequisites

Before diving into the code, make sure you have the following in place:

  • A Next.js application with Better Auth already set up (the popular better-auth npm package).
  • An authentication server that exposes a JWKS endpoint (e.g., https://your-auth-server.com/.well-known/jwks.json). If you’re using a third‑party provider like Auth0, they provide this URL in their application settings.
  • Basic familiarity with JWT structure (header, payload, signature) and Node.js fetch (or a library like jsonwebtoken for verification).

I’ll use TypeScript throughout the examples, but the same logic applies to plain JavaScript.

Step‑by‑step implementation with Better Auth

Better Auth allows you to override the default JWT verification via its verify configuration option. By default, it reads a local public key from environment variables. We’ll replace that with a function that fetches the JWKS, picks the correct key, and verifies the token.

Fetching and caching JWKS

Hitting the JWKS endpoint on every request is expensive and can introduce latency. It’s better to fetch once and cache the key set, refreshing it periodically or when a key is not found.

Here’s a simple cache module that stores the keys in memory and refreshes them every hour:

typescript
// lib/jwks.ts
import { createRemoteJWKSet } from 'jose';

const jwksUri = process.env.JWKS_URI!; // e.g., https://example.com/.well-known/jwks.json

export const JWKS = createRemoteJWKSet(new URL(jwksUri), {
  cacheMaxAge: 3600_000, // 1 hour in milliseconds
  cooldownDuration: 60_000, // 1 minute cooldown on failures
});

I’m using the jose library (npm install jose) because it provides a dedicated createRemoteJWKSet function that handles caching, background refresh, and key matching. It returns an object with a (keyID?) => KeyLike interface – exactly what we need.

If you prefer to manage caching yourself, you can use fetch and store the keys in a Map, but jose’s built‑in approach is simpler and battle‑tested.

Verifying a JWT using JWKS

Now, with the JWKS set ready, we can verify a token. The jose library provides jwtVerify that accepts the JWKS set and automatically selects the correct key based on the token’s kid header.

typescript
import { jwtVerify } from 'jose';
import { JWKS } from './jwks';

export async function verifyToken(token: string) {
  try {
    const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
      issuer: 'https://your-issuer.com',
      audience: 'your-api-audience',
    });
    return { payload, protectedHeader };
  } catch (error) {
    console.error('Token verification failed:', error);
    return null;
  }
}

The jwtVerify function returns the decoded payload if the signature is valid and the token has not expired. It also checks the iss and aud claims if you pass the options – a good security practice.

Handling key rotation

The beauty of using createRemoteJWKSet is that rotation happens behind the scenes. When a token’s kid does not match any cached key, jose automatically fetches a fresh JWKS from the endpoint. If the key still isn’t found, it throws an error – meaning the token was signed with a key that has been fully removed. In that case, the token should be considered invalid.

You can customize the cache behavior with the options shown earlier. For most services, a one‑hour cache with a short cooldown on failure is a good balance between performance and freshness.

Integration with Better Auth’s verify callback

Better Auth exposes a verify configuration in the betterAuth call. Here’s how to wire up our JWKS‑based verification:

typescript
// auth.ts
import { betterAuth } from 'better-auth';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { cookies, headers } from 'next/headers';

const jwksUri = process.env.JWKS_URI!;
const JWKS = createRemoteJWKSet(new URL(jwksUri), { cacheMaxAge: 3600_000 });

export const auth = betterAuth({
  database: /* your database config */,
  verify: async (token: string) => {
    try {
      const { payload } = await jwtVerify(token, JWKS, {
        issuer: process.env.TOKEN_ISSUER,
        audience: process.env.TOKEN_AUDIENCE,
      });
      // Better Auth expects the user object or null
      // Map the JWT payload to your user model
      return {
        id: payload.sub as string,
        email: payload.email as string,
        // any other claims you need
      };
    } catch {
      return null;
    }
  },
  // ... other options
});

That’s it. Every time a request comes in with a JWT (via cookie or Authorization header), Better Auth calls this verify callback. Our function uses jose to validate the token against our remote JWKS. If the token is expired or the signature doesn’t match, it returns null, and Better Auth treats the request as unauthenticated.

Important: The verify callback must return null for invalid tokens, not throw an error. Better Auth relies on the return value to decide whether to set the user context.

Testing and debugging

To verify your implementation works, run a few tests:

  1. Fetch a valid token from your auth server (e.g., through a login flow).
  2. Call a protected API route with that token. If everything is set up correctly, you should see the authenticated user.
  3. Wait for the JWKS cache to expire (or clear it manually), then make another request. The token should still be verified because jose fetches a fresh key set.
  4. Rotate the signing key on your auth server. Old tokens signed with the previous key should stay valid as long as the old key remains in the JWKS endpoint. Once the old key is removed, those tokens become invalid – which is the expected behaviour.

For debugging, add logging inside the verify callback to see which key ID was matched and whether the JWKS fetch is happening. You can also inspect the JWKS endpoint manually by visiting it in your browser – it should return a JSON object with a keys array.

Security considerations

  • Always use HTTPS for your JWKS endpoint. If an attacker can intercept the key set, they can forge tokens.
  • Validate the issuer and audience in jwtVerify. An attacker could reuse a token meant for another service if you don’t restrict these claims.
  • Set an appropriate cache TTL. Too long (e.g., 24 hours) and your app may reject valid tokens after a key rotation. Too short (e.g., 5 minutes) and you increase load on your auth server. One hour is a common default.
  • Have a fallback for failed JWKS fetches. If your auth server is temporarily unreachable, your app will reject all tokens. Consider caching the last successful JWKS response and falling back to it on network failure. The jose library’s built‑in caching already provides a cooldown, which mitigates the worst cases.

By replacing a static public key with a JWKS endpoint, you remove a significant operational burden. Better Auth makes the integration minimal, and with jose handling the heavy lifting, your code stays lean and focused on authentication logic rather than RFC parsing. Whether you’re using a third‑party identity provider or running your own OAuth server, this pattern ensures your application can handle key changes without downtime.

Comments