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 sensitiveJWS
  • Claims are sensitive / must be hidden even in transit or logsJWE
  • Both sensitive and must be verifiably from issuerNested (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 or ECDH-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<br/>sub, aud, exp, ...] A2[Sign claims → JWS<br/>(RS256/ES256)<br/>header has kid] A3[Encrypt JWS → JWE<br/>(RSA-OAEP-256/ECDH-ES + A256GCM)<br/>header has kid] end subgraph Receiver App (B) B1[Receive JWE] B2[Decrypt with private key<br/>→ inner JWS] B3[Read JWS header.kid] B4[Fetch/cache JWKS from jwks_uri<br/>pick matching key by kid] B5[Verify JWS signature<br/>& 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=<signing kid>) A->>A: Encrypt JWS → JWE (header: alg=RSA-OAEP-256, enc=A256GCM, kid=<B enc 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 unique kid.
  • 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:
  1. Publish new key in JWKS.
  2. Start signing with its kid.
  3. Keep old key until old tokens expire.
  4. Remove old key from JWKS.

Minimal JWKS example

{ "keys": [ { "kty": "RSA", "kid": "rsa-2025-09-01", "use": "sig", "alg": "RS256", "n": "<base64url modulus>", "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.

Source: DEV Community.


Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

The reCAPTCHA verification period has expired. Please reload the page.