JWT vs JWS vs JWE: What’s the Difference & When to Use Each




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 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
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
Enter fullscreen mode

Exit fullscreen mode



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
Enter fullscreen mode

Exit fullscreen mode




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 ... */ };
Enter fullscreen mode

Exit fullscreen mode




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)
Enter fullscreen mode

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode




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)
Enter fullscreen mode

Exit fullscreen mode

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`
Enter fullscreen mode

Exit fullscreen mode




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
Enter fullscreen mode

Exit fullscreen mode

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`
Enter fullscreen mode

Exit fullscreen mode




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": "",
      "e": "AQAB"
    }
  ]
}
Enter fullscreen mode

Exit fullscreen mode




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 link

Leave a Reply

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