JWT, JWS, JWE — a practical mini-guide (with issuer/receiver examples)
How JWT, JWS and JWE Work Together in Token Security
1) Quick definitions
- JWT (JSON Web Token): A standard container for claims. A JWT can be signed (JWS) or encrypted (JWE), or both (nested).
- JWS (JSON Web Signature): A signed JWT (integrity + authenticity). Header & payload are readable (Base64URL), but protected against tampering.
- JWE (JSON Web Encryption): An encrypted JWT (confidentiality + integrity). Claims are hidden from anyone without the decryption key.
Shapes (compact serialization)
-
JWS:
header.payload.signature
(3 parts) -
JWE:
header.encryptedKey.iv.ciphertext.tag
(5 parts)
2) Differences at a glance
Aspect | JWS (signed) | JWE (encrypted) |
---|---|---|
Confidentiality (hides data) | ❌ No | ✅ Yes |
Integrity / authenticity | ✅ Yes | ✅ Yes |
Readability by intermediaries | Readable | Opaque |
Common use | Access tokens between trusted services over TLS | Sensitive claims (PII/financial), B2B |
Parts | 3 | 5 |
Who is “more secure”?
- For hiding data, JWE wins (it’s encrypted).
- For tamper-proofing, both JWS and JWE provide integrity.
- If you need both confidentiality and a verifiable issuer, use nested: Sign → Encrypt (JWS inside JWE).
3) When should I use what? (cheat sheet)
- Only need to verify the issuer and prevent tampering; claims aren’t sensitive → JWS
- Claims are sensitive / must be hidden even in transit or logs → JWE
- Both sensitive and must be verifiably from issuer → Nested (JWS → JWE)
Always use HTTPS/TLS regardless.
4) Key concepts you’ll see below
-
alg
: crypto algorithm (e.g.,RS256
for JWS,RSA-OAEP-256
orECDH-ES
+A256GCM
for JWE) -
kid
: Key ID in the header, so verifiers can pick the right key -
jwks_uri
: URL where the issuer publishes public keys (JWKS) so others can verify signatures - Claims to validate:
iss
,aud
,exp
,nbf
,iat
5) Architecture flow (Mermaid)
Paste these into a Mermaid-friendly renderer.
High-level flow (sign → encrypt → decrypt → verify)
flowchart LR
subgraph Issuer App (A)
A1[Build claims
sub, aud, exp, ...]
A2[Sign claims → JWS
(RS256/ES256)
header has kid]
A3[Encrypt JWS → JWE
(RSA-OAEP-256/ECDH-ES + A256GCM)
header has kid]
end
subgraph Receiver App (B)
B1[Receive JWE]
B2[Decrypt with private key
→ inner JWS]
B3[Read JWS header.kid]
B4[Fetch/cache JWKS from jwks_uri
pick matching key by kid]
B5[Verify JWS signature
& validate iss, aud, exp, nbf]
B6[Use claims]
end
A1 --> A2 --> A3 --> B1 --> B2 --> B3 --> B4 --> B5 --> B6
Sequence (with kid
+ jwks_uri
)
sequenceDiagram
participant A as Issuer App (A)
participant K as Key Server (Issuer JWKS)
participant B as Receiver App (B)
Note over A: Signing key pair (private kept by A)
Note over B: Encryption key pair (private kept by B)
A->>A: Create claims (sub, aud, exp, iat, nbf, iss)
A->>A: Sign → JWS (header: alg=RS256, kid=)
A->>A: Encrypt JWS → JWE (header: alg=RSA-OAEP-256, enc=A256GCM, kid=)
A-->>B: Send JWE (5 parts)
B->>B: Decrypt with B's private key → inner JWS
B->>B: Read JWS.header.kid
B->>K: GET {jwks_uri} (issuer’s JWKS; cache with ETag/Cache-Control)
K-->>B: JWKS { keys:[ {kid:..., kty:..., ...}, ... ] }
B->>B: Select key where kid == JWS.kid
B->>B: Verify signature; check iss, aud, exp, nbf, iat, alg allow-list
B->>B: Accept claims → authorize request
6) Examples — Node.js with jose
Replace the example JWKs with your real keys (ideally from a KMS/HSM). Algorithms shown are secure defaults.
6.1 Keys (outline)
-
Issuer app (A) keeps signing private key (RS256/ES256) → publishes the public key in JWKS at
jwks_uri
, with a uniquekid
. - Receiver app (B) keeps encryption private key (RSA-OAEP-256 or ECDH-ES). Issuer needs B’s public key to encrypt.
// Shapes only (don’t use these literals in prod)
const signingPrivateJwk = { kty: 'RSA', kid: 'rsa-2025-09-01', alg: 'RS256', /* ... d, n, e ... */ };
const signingPublicJwk = { kty: 'RSA', kid: 'rsa-2025-09-01', alg: 'RS256', /* ... n, e ... */ };
const encPublicJwkOfB = { kty: 'RSA', kid: 'enc-2025-06-01', alg: 'RSA-OAEP-256', /* ... n, e ... */ };
const encPrivateJwkOfB = { kty: 'RSA', kid: 'enc-2025-06-01', alg: 'RSA-OAEP-256', /* ... d, n, e ... */ };
6.2 JWS only (create & verify)
Issuer (A) → create JWS
import { SignJWT, importJWK } from 'jose';
const key = await importJWK(signingPrivateJwk, 'RS256');
const now = Math.floor(Date.now()/1000);
const jws = await new SignJWT({ sub: 'user_123', scope: 'orders:read', iat: now })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: signingPrivateJwk.kid })
.setIssuer('https://issuer.example.com/')
.setAudience('api://receiver-app')
.setExpirationTime('15m')
.sign(key);
// send `jws` to B (3 parts)
Receiver (B) → verify JWS
import { jwtVerify, importJWK } from 'jose';
const verifyKey = await importJWK(signingPublicJwk, 'RS256');
const { payload, protectedHeader } = await jwtVerify(jws, verifyKey, {
algorithms: ['RS256'],
issuer: 'https://issuer.example.com/',
audience: 'api://receiver-app',
maxTokenAge: '15m',
clockTolerance: '60s'
});
// use payload
6.3 JWE only (create & decrypt)
Issuer (A) → create JWE
import { compactEncrypt, importJWK } from 'jose';
const pubEncKey = await importJWK(encPublicJwkOfB, 'RSA-OAEP-256');
const plaintext = new TextEncoder().encode(JSON.stringify({ ssn: '***-**-1234' }));
const jwe = await new compactEncrypt(plaintext)
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', typ: 'JWT', kid: encPublicJwkOfB.kid })
.encrypt(pubEncKey);
// send `jwe` to B (5 parts)
Receiver (B) → decrypt JWE
import { compactDecrypt, importJWK } from 'jose';
const privEncKey = await importJWK(encPrivateJwkOfB, 'RSA-OAEP-256');
const { plaintext, protectedHeader } = await compactDecrypt(jwe, privEncKey);
const data = JSON.parse(new TextDecoder().decode(plaintext));
// use decrypted `data`
6.4 Nested (recommended when you need both): Sign → Encrypt
Issuer (A): sign JWS, then encrypt that JWS into a JWE
import { SignJWT, compactEncrypt, importJWK } from 'jose';
const signKey = await importJWK(signingPrivateJwk, 'RS256');
const encPub = await importJWK(encPublicJwkOfB, 'RSA-OAEP-256');
const jws = await new SignJWT({ sub: 'user_123', role: 'admin' })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: signingPrivateJwk.kid })
.setIssuer('https://issuer.example.com/')
.setAudience('api://receiver-app')
.setExpirationTime('10m')
.sign(signKey);
const jwe = await new compactEncrypt(new TextEncoder().encode(jws))
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', typ: 'JWT', kid: encPublicJwkOfB.kid })
.encrypt(encPub);
// send `jwe` to B
Receiver (B): decrypt JWE → verify inner JWS
import { compactDecrypt, jwtVerify, importJWK } from 'jose';
const decKey = await importJWK(encPrivateJwkOfB, 'RSA-OAEP-256');
const verifyKey = await importJWK(signingPublicJwk, 'RS256');
const { plaintext, protectedHeader: encHdr } = await compactDecrypt(jwe, decKey);
const innerJws = new TextDecoder().decode(plaintext);
const { payload, protectedHeader: sigHdr } = await jwtVerify(innerJws, verifyKey, {
algorithms: ['RS256'],
issuer: 'https://issuer.example.com/',
audience: 'api://receiver-app',
maxTokenAge: '10m',
clockTolerance: '60s'
});
// use `payload`
7) Publishing and discovering keys
- Add
kid
to token headers. - Publish the issuer’s JWKS at a stable
jwks_uri
(e.g.,https://issuer.example.com/.well-known/jwks.json
). - Receivers fetch & cache JWKS, then pick the key with matching
kid
to verify. - Rotate keys by:
- Publish new key in JWKS.
-
Start signing with its
kid
. - Keep old key until old tokens expire.
- Remove old key from JWKS.
Minimal JWKS example
{
"keys": [
{
"kty": "RSA",
"kid": "rsa-2025-09-01",
"use": "sig",
"alg": "RS256",
"n": "" ,
"e": "AQAB"
}
]
}
8) Best practices (security checklist)
- Enforce algorithm allow-lists (e.g.,
RS256
,ES256
; for JWE:RSA-OAEP-256
/ECDH-ES
+A256GCM
). - Validate
iss
,aud
,exp
,nbf
,iat
(+ small clock tolerance). - Keep access tokens short-lived (5–15 min). Use rotating refresh tokens.
- Prefer HttpOnly+Secure+SameSite cookies in browsers; always use TLS.
- Don’t put secrets/PII in a plain JWS; use JWE or keep tokens opaque.
- Log failed verifications; monitor unusual reuse or audience/issuer mismatches.
9) FAQ
- Is JWT itself encrypted? No. JWT is just the container. Use JWE for encryption.
- 3 parts vs 5 parts? 3 → JWS (signed). 5 → JWE (encrypted).
- Order for nested? Sign → Encrypt (hides claims and signature metadata).
TL;DR
- JWS: integrity + authenticity. Fast, readable.
- JWE: confidentiality (+ integrity). Opaque.
- Nested (JWS→JWE): both. Use for sensitive data that must be verifiably from your issuer.