Skip to main content

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 Authorization header.
  • Use the Node.js runtime to access node:crypto.

Steps

  1. Read the token from cookies or headers in a server-only path.
  2. Decode the JWT header (no verification) to get kid and alg.
  3. Fetch the JWKS from your auth provider and cache it in memory with a TTL.
  4. Select the matching key by kid or fall back to all keys if kid is missing.
  5. Convert the JWK to a public key and verify the signature with RS256.
  6. 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 (RS256 only).
  • Prefer issuer and audience checks if you have stable values.
  • Cache JWKS and refresh on TTL or when a kid is missing (key rotation).

Validation

  • Missing or invalid tokens return 401.
  • Valid tokens return claims you can use for authorization.