JSON Web Key (JWK): Structure, JWKS Endpoints, and Node.js Verification
A JSON Web Key (JWK, RFC 7517) is a JSON object that represents a cryptographic key — used to publish public keys for JWT signature verification, rotate keys with minimal downtime, and share key material between services without proprietary formats. A JWK Set ({"keys": [...]}) is typically published at a JWKS endpoint like /.well-known/jwks.json; key rotation works by adding a new key to the set before retiring the old one — clients cache the set for 1–24 hours and re-fetch on a kid mismatch. This guide covers JWK structure (kty, kid, use, alg, n/e for RSA, x/y for EC), JWKS endpoints, verifying JWTs with a JWKS URL in Node.js using the jose library, zero-downtime key rotation strategy, and converting between PEM and JWK. To validate any JWK object before publishing it, use Jsonic's JSON formatter.
Need to inspect or prettify a JWK or JWKS response? Jsonic's JSON formatter handles it instantly.
Open JSON FormatterJWK structure: required and optional fields
A JSON Web Key is a plain JSON object. Only 1 field (kty) is required by RFC 7517; all others are optional but strongly recommended for security. The 4 most important members are kty, kid, use, and alg. Key-type-specific parameters carry the actual key material: RSA keys use n (modulus) and e (exponent) for public keys, plus d, p, q for private keys. EC keys use crv (curve name), x, and y.
// RSA public key JWK (RS256 — most common for JWT signing)
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "2026-05-primary",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
"e": "AQAB"
}
// EC public key JWK (ES256 — smaller keys, same security as RSA-2048)
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "ec-2026-05",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
// JWKS endpoint response — the keys array wraps multiple JWKs
{
"keys": [
{ "kty": "RSA", "use": "sig", "kid": "2026-05-primary", "alg": "RS256", "n": "...", "e": "AQAB" },
{ "kty": "RSA", "use": "sig", "kid": "2026-04-retiring", "alg": "RS256", "n": "...", "e": "AQAB" }
]
}The n (modulus) field encodes the RSA public key's large integer in Base64url format — typically 342 characters for a 2048-bit key. The e (exponent) is almost always AQAB, which is the Base64url encoding of the integer 65537 (the standard RSA public exponent). For EC keys, x and y are the 32-byte coordinates of the public point on the P-256 curve, each Base64url-encoded to 43 characters. To understand Base64url encoding (which differs from standard Base64 by using - and _ instead of + and /, with no padding), see the Base64 guide.
JWK field reference: every member explained
RFC 7517 defines the common JWK members; RFC 7518 defines algorithm-specific parameters. The table below covers all 10 fields you will encounter in practice across RSA and EC keys. Understanding each field is essential when debugging a kid mismatch or an algorithm confusion vulnerability.
| Field | Required | Values / Notes |
|---|---|---|
kty | Yes | RSA, EC, oct (symmetric HMAC) |
kid | No (strongly recommended) | Arbitrary string; matched against JWT header kid for key selection |
use | No | sig (signing/verification) or enc (encryption) |
alg | No (recommended) | RS256, RS512, ES256, ES384, PS256, EdDSA |
key_ops | No | Array: sign, verify, encrypt, decrypt; alternative to use |
n | RSA only | RSA modulus — Base64url-encoded big integer (342 chars for 2048-bit) |
e | RSA only | RSA public exponent — almost always AQAB (= 65537) |
crv | EC only | P-256, P-384, P-521, Ed25519 |
x | EC only | EC public key x-coordinate (Base64url, 43 chars for P-256) |
y | EC only (not Ed25519) | EC public key y-coordinate (Base64url, 43 chars for P-256) |
Always include alg when publishing a JWKS for JWT verification. Without it, a permissive verifier might accept a token with "alg": "none" or accept an RSA key for HMAC verification — the 2 most common JWT algorithm confusion attacks. The use and key_ops fields are mutually exclusive; RFC 7517 says not to include both. For most JWKS endpoints, use: "sig" is the right choice. To understand how alg maps to JWT header values, see the how JWT works guide.
Verify a JWT using a JWKS URL in Node.js (jose library)
The jose npm library is the recommended tool for JWKS-based JWT verification in Node.js. It supports all 3 major algorithm families (RSA, ECDSA, EdDSA), handles JWKS caching with automatic cache invalidation on kid mismatch, deduplicates concurrent fetches, and runs in Node.js, Deno, and the browser without polyfills. Install it with 1 command:
npm install joseimport { createRemoteJWKSet, jwtVerify } from 'jose'
// Call once at startup — caches JWKS in memory
const JWKS = createRemoteJWKSet(
new URL('https://your-auth-server/.well-known/jwks.json')
)
// Call on every incoming request
async function verifyToken(token: string) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: 'https://your-auth-server', // must match iss claim
audience: 'https://your-api.example.com', // must match aud claim
})
// payload is the verified JWT claims object
return payload
}
// Express middleware example
app.use(async (req, res, next) => {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return res.sendStatus(401)
try {
req.user = await verifyToken(authHeader.slice(7))
next()
} catch (err) {
res.status(401).json({ error: 'Invalid token', detail: String(err) })
}
})The issuer and audience options are critical — always pass them. Without issuer validation, a token signed by a different identity provider that happens to share the same JWKS URL would be accepted. Without audience validation, a token issued for a different API in the same ecosystem would be accepted. Both checks prevent token substitution attacks at near-zero cost. The createRemoteJWKSet function caches the JWKS for 15 minutes by default and re-fetches when it sees a kid not in its cache — which is exactly the behavior you need for zero-downtime key rotation.
For environments where you already have the JWKS JSON locally (e.g. downloaded from a config service at startup), use createLocalJWKSet instead:
import { createLocalJWKSet, jwtVerify } from 'jose'
const jwks = await fetch('https://auth-server/.well-known/jwks.json').then(r => r.json())
const JWKS = createLocalJWKSet(jwks) // uses the in-memory object, no HTTP calls
const { payload } = await jwtVerify(token, JWKS, { issuer: '...', audience: '...' })To understand the full JWT structure that these keys verify, including header, payload, and signature fields, see the how JWT works guide. To decode and inspect a JWT's claims without verification, see decode JWT.
JWKS endpoint setup and caching strategy
A JWKS endpoint must return a JSON object with exactly 1 key: keys, whose value is an array of JWK objects. The standard path is /.well-known/jwks.json (RFC 8615). Serve it over HTTPS only, set aCache-Control header, and never include private key material (no d, p, q fields). A minimal Express.js JWKS server:
import express from 'express'
import { exportJWK, importPKCS8, importSPKI } from 'jose'
const app = express()
// Load your RSA public key (never expose the private key)
const publicKeyPem = process.env.RSA_PUBLIC_KEY!
const publicKey = await importSPKI(publicKeyPem, 'RS256')
const jwk = await exportJWK(publicKey)
// Add metadata fields
const publicJwk = {
...jwk,
kid: '2026-05-primary',
use: 'sig',
alg: 'RS256',
}
app.get('/.well-known/jwks.json', (_req, res) => {
res.set('Cache-Control', 'public, max-age=3600') // 1 hour client cache
res.json({ keys: [publicJwk] })
})3 caching rules for JWKS clients: (1) Cache for at least 5 minutes to avoid hammering the JWKS endpoint under high traffic — every microservice verifying JWTs will hit this endpoint on startup if there is no cache. (2) Re-fetch immediately on kid mismatch — when a JWT arrives with a kid not in the cache, fetch the JWKS once more before rejecting the token. This handles the key rotation transition window where new JWTs signed with the new key arrive before the cache expires. (3) Respect the Cache-Control: max-age header from the server — if the server signals a 24-hour TTL, the client should honour it rather than fetching every minute. The jose library's createRemoteJWKSet implements all 3 rules automatically.
Zero-downtime key rotation with JWKs
Key rotation is the primary reason to use JWKs over static PEM files. Because the JWKS endpoint is a live URL rather than a file embedded in each service, rotating a key requires changes in exactly 1 place: the authorization server's key store. The 4-step rotation playbook keeps all 3 services (auth server, JWKS endpoint, resource servers) in sync throughout the transition:
// Step 1 — JWKS before rotation: only old key
{ "keys": [{ "kid": "key-2025-11", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" }] }
// Step 2 — Add new key (BOTH keys in JWKS, not signing yet with new key)
{ "keys": [
{ "kid": "key-2026-05", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" },
{ "kid": "key-2025-11", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" }
]}
// Step 3 — Auth server switches to signing JWTs with key-2026-05
// (key-2025-11 still in JWKS to validate previously issued tokens)
// Step 4 — After JWT TTL elapses (e.g. 1 hour), remove old key
{ "keys": [{ "kid": "key-2026-05", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" }] }The critical timing rule: the overlap window (Step 2 through Step 4) must be at least as long as your JWT expiry. If your tokens expire in 1 hour, you must keep the old key in the JWKS for at least 1 hour after you stop signing with it. A safe default is 2 × JWT TTL — this covers the edge case where a token is issued just before the switch in Step 3 and is validated just before it expires. For refresh tokens, which can have TTLs of days or weeks, you need a longer overlap — or invalidate old refresh tokens explicitly on rotation. Never delete a key from your JWKS before confirming that no live tokens were signed with it. Monitoring kid values in your access logs is the most reliable signal.
Convert PEM keys to JWK format in Node.js
If your infrastructure generates RSA or EC keys as PEM files (via OpenSSL, AWS KMS exports, or a certificate authority), convert them to JWK with 3 lines using jose. The conversion is lossless — all key material is preserved; you just add metadata fields (kid, use, alg) manually after export.
import { importSPKI, importPKCS8, exportJWK } from 'jose'
import { readFileSync } from 'fs'
// PEM public key → JWK (safe to publish at JWKS endpoint)
const pubPem = readFileSync('public.pem', 'utf8')
const publicKey = await importSPKI(pubPem, 'RS256')
const publicJwk = await exportJWK(publicKey)
publicJwk.kid = 'my-key-2026-05'
publicJwk.use = 'sig'
publicJwk.alg = 'RS256'
console.log(JSON.stringify(publicJwk, null, 2))
// PEM private key → JWK (keep secret — use only on auth server)
const privPem = readFileSync('private.pem', 'utf8')
const privateKey = await importPKCS8(privPem, 'RS256')
const privateJwk = await exportJWK(privateKey)
privateJwk.kid = 'my-key-2026-05' // same kid as public key
privateJwk.use = 'sig'
privateJwk.alg = 'RS256'# Generate a 2048-bit RSA key pair with OpenSSL (then import with jose above)
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pemTo go the other direction — JWK back to PEM — use importJWK and exportSPKI:
import { importJWK, exportSPKI } from 'jose'
const jwkObject = { kty: 'RSA', alg: 'RS256', use: 'sig', n: '...', e: 'AQAB' }
const key = await importJWK(jwkObject, 'RS256')
const pem = await exportSPKI(key)
// -----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhki...
-----END PUBLIC KEY-----Never use online PEM-to-JWK converters for private keys. For public keys only, online tools are safe — but always validate the output JSON with a JSON formatter before publishing it. The most common conversion errors are missing padding in Base64url values, trailing newlines in the PEM string, and forgetting to add kid / alg after export. If your JWK has a d field, it is a private key — strip it before publishing.
JWK vs PEM vs PKCS#12: choosing the right key format
Modern systems use 3 key formats in practice. JWK dominates for API-based systems; PEM remains the most common format in file-based infrastructure. Choosing the wrong format adds unnecessary conversion overhead and complicates key distribution. The table below compares all 3 across 6 dimensions:
| JWK / JWKS | PEM | PKCS#12 (.p12 / .pfx) | |
|---|---|---|---|
| Format | JSON | Base64 + headers | Binary (DER) |
| Standard | RFC 7517 / RFC 7518 | RFC 7468 / PKCS#8 | RFC 7292 |
| Metadata | kid, use, alg, crv built-in | None (filename convention) | Certificates, chain, alias |
| Multiple keys | JWKS keys array | Concatenate PEM blocks | Multiple bags |
| Best for | JWT verification, public key discovery | TLS, SSH, OpenSSL toolchain | Client TLS certificates, code signing |
| Password protection | No (rely on secrets manager) | Optional (encrypted PEM) | Yes (mandatory) |
Use JWK when building token-based authentication (OAuth 2.0, OIDC, or custom JWTs): the kid and alg fields map directly to JWT header values, and the JWKS URL pattern enables automatic key discovery by any compliant client. Use PEM for TLS certificates and server-to-server mTLS. Use PKCS#12 for client certificates that need password protection and must be distributed as a bundle including the certificate chain. For JWT work, JWK is the only format where the standard ecosystem (Auth0, Okta, Google, Cognito, Keycloak) has converged — every major identity provider exposes a /.well-known/jwks.json endpoint.
Frequently asked questions
What is a JSON Web Key (JWK) and how is it different from a PEM key?
A JSON Web Key (JWK) is a JSON object that represents a cryptographic key, defined in RFC 7517. It encodes the same key material as a PEM file but in a structured JSON format that is far easier to parse, embed in API responses, and extend with metadata. A PEM file is a Base64-encoded DER binary wrapped in -----BEGIN PUBLIC KEY----- / -----END PUBLIC KEY----- headers — a flat blob with no metadata. A JWK carries the same key parameters (modulus and exponent for RSA, x and y coordinates for EC) plus metadata fields: kty (key type), kid (key ID for rotation), use (sig or enc), alg (RS256, ES256, etc.), and key_ops. Because JWKs are JSON, they embed directly in API responses, store cleanly in databases, and version naturally in configuration files without special encoding. A JWKS (JWK Set) is a JSON object with a keys array containing multiple JWKs, published at a well-known URL so that any relying party can fetch the current public keys automatically. Use Jsonic's JSON formatter to inspect and validate any JWK object.
What is a JWKS endpoint and why is it used?
A JWKS endpoint is an HTTP URL that returns a JSON object containing an array of public keys. The standard path is /.well-known/jwks.json. Identity providers like Auth0, Okta, Google, and Cognito publish their signing public keys at this URL so that resource servers can fetch the keys dynamically and verify JWTs without any out-of-band key distribution. The workflow: (1) your application receives a JWT; (2) it reads the kid (key ID) from the JWT header; (3) it fetches the JWKS URL and finds the key with the matching kid; (4) it verifies the JWT signature using that public key. Clients cache the JWKS response according to Cache-Control headers — typically 1 to 24 hours — and only re-fetch when they receive a JWT with a kid not present in their cache. This cache-on-mismatch strategy allows key rotation with zero downtime: add a new key to the JWKS before retiring the old one, and clients will transparently upgrade to the new key. The 15-minute default cache in jose's createRemoteJWKSet covers 99% of production use cases without configuration.
How do I verify a JWT using a JWKS URL in Node.js?
The fastest path in Node.js is the jose library (npm install jose). Call createRemoteJWKSet(new URL(jwksUrl)) once at startup to get a key-fetching function, then pass it to jwtVerify for each incoming token. jose handles fetching, caching, cache invalidation on kid mismatch, and concurrent request deduplication automatically. The pattern: import { createRemoteJWKSet, jwtVerify } from 'jose'; then const JWKS = createRemoteJWKSet(new URL('https://auth/.well-known/jwks.json')); then per-request const { payload } = await jwtVerify(token, JWKS, { issuer, audience }). Always pass issuer and audience — without them, a token signed by a different identity provider using the same JWKS URL would be accepted. jose supports RSA (RS256, RS384, RS512), ECDSA (ES256, ES384, ES512), and EdDSA (Ed25519). For an older approach, use jsonwebtoken + jwks-rsa, though jose is recommended for new projects because it uses the Web Crypto API natively in Node.js 18+ with no native addons. See also how to decode JWT for inspection without verification.
What do the kty, kid, use, and alg fields mean in a JWK?
These are the 4 most important JWK member fields defined in RFC 7517 and RFC 7518. kty (key type) is required and specifies the cryptographic algorithm family: RSA for RSA keys, EC for elliptic curve keys, and oct for symmetric (HMAC) keys. kid (key ID) is an optional string that identifies a specific key within a JWKS — it is included in the JWT header so the verifier knows which key to use, and it is the primary mechanism for key rotation across 2 or more simultaneously active keys. use (public key use) indicates intended use: sig for signing and verification, enc for encryption and decryption. alg names the specific algorithm: RS256 (RSASSA-PKCS1-v1_5 with SHA-256), ES256 (ECDSA with P-256 and SHA-256), RS512, ES384, etc. When alg is present in a JWK, any JWT using this key must declare the same algorithm in its header — this prevents algorithm confusion attacks where an attacker substitutes a weaker algorithm. For RSA public keys, n (modulus, Base64url) and e (exponent, almost always AQAB = 65537) carry the actual key material. Understanding Base64url encoding is helpful for reading raw JWK values.
How does JWK key rotation work without downtime?
Zero-downtime key rotation exploits JWKS client caching behavior in a 4-step process. Step 1 — Generate: create a new key pair and assign it a new kid (e.g. key-2026-05). Step 2 — Publish: add the new public key to your JWKS endpoint alongside the old key. At this point, no JWTs are yet signed with the new key, so all existing tokens still verify against the old key. Both keys coexist in the keys array. Step 3 — Sign: update your authorization server to sign new JWTs with the new key. Existing short-lived JWTs signed with the old key continue to verify because the old public key is still in the JWKS. Resource servers hit a kid mismatch, re-fetch the JWKS, find both keys, and verify both old and new tokens. Step 4 — Retire: after the maximum JWT TTL has elapsed (at least 2 × your JWT expiry to be safe), remove the old key from the JWKS. For refresh tokens with long TTLs, either extend the overlap window or invalidate old refresh tokens explicitly. Never delete a key while any live token signed with it may still arrive at your API.
How do I convert a PEM key to JWK format?
In Node.js, jose converts between PEM and JWK in 3 lines. For a public key: import { importSPKI, exportJWK } from 'jose'; then const key = await importSPKI(pemString, 'RS256'); then const jwk = await exportJWK(key). Add metadata afterward: jwk.kid = 'my-key-id'; jwk.use = 'sig'; jwk.alg = 'RS256'. For a private key, use importPKCS8 instead of importSPKI. To go JWK → PEM: use importJWK and exportSPKI. Never use online converters for private keys — only use them for public keys in non-production contexts. The most common conversion mistakes are: (1) forgetting to add kid / alg after export (the exportJWK output does not include these metadata fields automatically); (2) keeping the d field (private exponent) in the published JWK — if your JWK has a d field, it is a private key and must never be published; (3) trailing newlines in the PEM string causing import errors. Always validate the final JSON with Jsonic's formatter before publishing.
Ready to work with JSON Web Keys?
Use Jsonic's JSON formatter to validate and prettify any JWK or JWKS response. You can also use the JWT decoder guide to inspect a JWT's kid and alg header before looking up the key in your JWKS.