Validate JWTs with JWKS (Next.js)
Doc type: How-to guide
Goal and scope
Verify Yoyo Auth JWTs server-side in Next.js so you can trust claims before enforcing authorization.
Prerequisites and constraints
- Run the validation in a server-only path (route handler or server component).
- Read the token from an HttpOnly cookie or an
Authorizationheader. - Use the Node.js runtime to access
node:crypto.
Steps
- Read the token from cookies or headers in a server-only path.
- Decode the JWT header (no verification) to get
kidandalg. - Fetch the JWKS from your auth provider and cache it in memory with a TTL.
- Select the matching key by
kidor fall back to all keys ifkidis missing. - Convert the JWK to a public key and verify the signature with
RS256. - Reject any failure and only use claims after verification. Enforce roles and permissions.
// lib/jwks-auth.ts (server-only)
import 'server-only';
import { createPublicKey } from 'node:crypto';
import jwt, { type Jwt, type JwtHeader, type JwtPayload } from 'jsonwebtoken';
type JwksKey = JsonWebKey & { kid?: string };
const AUTH_ORIGIN = process.env.AUTH_ORIGIN ?? 'https://auth-int.yoyogroup.com';
const JWKS_URL = `${AUTH_ORIGIN}/api/oauth/jwks`;
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
let jwksCache: { keys: JwksKey[]; fetchedAt: number } | null = null;
const isCacheFresh = () =>
jwksCache !== null && Date.now() - jwksCache.fetchedAt < JWKS_CACHE_TTL_MS;
async function fetchJwks(): Promise<JwksKey[] | null> {
const res = await fetch(JWKS_URL, { cache: 'no-store' });
if (!res.ok) return null;
const data = (await res.json()) as { keys?: JwksKey[] };
if (!data.keys || !Array.isArray(data.keys)) return null;
jwksCache = { keys: data.keys, fetchedAt: Date.now() };
return data.keys;
}
async function getJwks(forceRefresh = false): Promise<JwksKey[] | null> {
if (!forceRefresh && isCacheFresh()) {
return jwksCache?.keys ?? null;
}
return fetchJwks();
}
function getJwtHeader(token: string): JwtHeader | null {
const decoded = jwt.decode(token, { complete: true }) as Jwt | null;
return decoded ? decoded.header : null;
}
export async function verifyJwtWithJwks(token: string): Promise<JwtPayload | null> {
const header = getJwtHeader(token);
if (!header || header.alg !== 'RS256') return null;
let jwks = await getJwks();
if (!jwks || jwks.length === 0) return null;
let candidates = header.kid ? jwks.filter((key) => key.kid === header.kid) : jwks;
if (header.kid && candidates.length === 0) {
jwks = await getJwks(true);
if (!jwks || jwks.length === 0) return null;
candidates = jwks.filter((key) => key.kid === header.kid);
}
for (const jwk of candidates) {
try {
const publicKey = createPublicKey({ key: jwk, format: 'jwk' });
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
if (!payload || typeof payload !== 'object') continue;
return payload;
} catch {
continue;
}
}
return null;
}
Usage (route handler example):
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { verifyJwtWithJwks } from '@/lib/jwks-auth';
export async function GET() {
const token = (await cookies()).get('yoyo_auth_access_token')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const claims = await verifyJwtWithJwks(token);
if (!claims) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ ok: true, sub: claims.sub });
}
Guardrails
- Run only on the server (never in client components).
- Enforce allowed algorithms (
RS256only). - Prefer issuer and audience checks if you have stable values.
- Cache JWKS and refresh on TTL or when a
kidis missing (key rotation).
Validation
- Missing or invalid tokens return
401. - Valid tokens return claims you can use for authorization.