JWT vs JWE: What's the Difference?
While both are part of the JOSE (JSON Object Signing and Encryption) family, they solve different problems:
- JWT (typically JWS) → focuses on integrity and authenticity of claims
- JWE → focuses on confidentiality via encryption
A helpful mental model:
- JWT (JWS) = a signed postcard → everyone can read it, but they can’t change it without breaking the signature.
- JWE = a sealed envelope → the message inside is hidden until you decrypt it.
In many real systems, you’ll see:
- Plain JWT (JWS) for access tokens and claims propagation
- JWE or nested JWT (JWS inside JWE) for sensitive data
Let’s walk through both, with practical examples.
Quick Refresher: JWT Basics (JWS)
A JWT (JSON Web Token) is often implemented as a JWS (JSON Web Signature): a set of claims formatted as JSON, then digitally signed.
Structure (compact form):
<header>.<payload>.<signature>
header– metadata (algorithm, type)payload– claims (subject, role, expiration, etc.)signature– proves integrity and authenticity
Example JWT Header and Payload
Header
{
"alg": "RS256",
"typ": "JWT"
}
Payload
{
"sub": "123456",
"name": "Alice",
"role": "admin",
"iat": 1736530800,
"exp": 1736534400
}
Anyone who gets this token can base64-decode and read the payload. The security comes from the signature, not from hiding the data.
Example: Signing and Verifying a JWT in Node.js (JWS)
Below is a minimal example using the jose library.
This example uses HS256 (symmetric key). In production, for multi-service architectures, prefer RS256/ES256 (asymmetric keys).
Installation
npm install jose
Signing a JWT (JWS)
// sign-jwt.js
import { SignJWT } from "jose";
const secretKey = new TextEncoder().encode(process.env.JWT_SECRET || "super-secret-key");
// Create and sign a JWT for user "alice"
async function createJwt() {
const jwt = await new SignJWT({
sub: "user_123",
name: "Alice",
role: "admin"
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m") // short-lived tokens are safer
.sign(secretKey);
console.log("JWT:", jwt);
}
createJwt().catch(console.error);
Verifying a JWT
// verify-jwt.js
import { jwtVerify } from "jose";
const secretKey = new TextEncoder().encode(process.env.JWT_SECRET || "super-secret-key");
async function verifyJwt(token) {
try {
const { payload, protectedHeader } = await jwtVerify(token, secretKey, {
algorithms: ["HS256"] // pin allowed algorithms
});
console.log("Header:", protectedHeader);
console.log("Payload:", payload);
} catch (err) {
console.error("Invalid token:", err.message);
}
}
// Example usage: pass token as CLI arg
const token = process.argv[2];
verifyJwt(token).catch(console.error);
Key points:
- We pin the allowed algorithms (
algorithms: ["HS256"]) to avoid algorithm confusion attacks. - The payload is readable; don’t put secrets there.
- Expiration (
exp) is enforced during verification.
What Is a JWE?
A JWE (JSON Web Encryption) is about encryption. It ensures that the enclosed payload is confidential and can only be read by someone with the correct decryption key.
Structure (compact form):
<protected-header>.<encrypted-key>.<iv>.<ciphertext>.<tag>
protected-header– metadata (encryption algorithm, key wrapping method)encrypted-key– encrypted symmetric key (when using asymmetric crypto)iv– initialization vector (nonce)ciphertext– encrypted payloadtag– authentication tag (for AEAD ciphers like GCM)
Example JWE Header
{
"alg": "RSA-OAEP-256",
"enc": "A256GCM",
"typ": "JWE"
}
Here:
alg= how the content encryption key (CEK) is wrapped/protected (e.g., RSA-OAEP-256)enc= how the payload itself is encrypted (e.g., AES-256-GCM)
With JWE, even if someone sees the token, the payload is unreadable until decrypted.
Example: Encrypting and Decrypting JWE in Node.js
We’ll again use jose, this time with asymmetric encryption:
- The sender uses the recipient’s public key to encrypt
- The recipient uses their private key to decrypt
1. Generate an RSA Key Pair (One-Time Setup)
You can do this with openssl:
# Private key
openssl genrsa -out jwe-private.pem 2048
# Public key
openssl rsa -in jwe-private.pem -pubout -out jwe-public.pem
2. Encrypting a Payload as JWE
// encrypt-jwe.js
import { importSPKI, CompactEncrypt } from "jose";
import fs from "node:fs/promises";
async function encryptSensitiveData() {
const publicKeyPem = await fs.readFile("jwe-public.pem", "utf-8");
// Import public key for encryption
const publicKey = await importSPKI(publicKeyPem, "RSA-OAEP-256");
const payload = new TextEncoder().encode(
JSON.stringify({
sub: "user_123",
email: "alice@example.com",
ssn_last4: "1234",
iat: Math.floor(Date.now() / 1000)
})
);
const jwe = await new CompactEncrypt(payload)
.setProtectedHeader({
alg: "RSA-OAEP-256",
enc: "A256GCM",
typ: "JWE"
})
.encrypt(publicKey);
console.log("JWE:", jwe);
}
encryptSensitiveData().catch(console.error);
The output is a compact JWE string. Its payload is encrypted and unreadable without the private key.
3. Decrypting a JWE
// decrypt-jwe.js
import { compactDecrypt, importPKCS8 } from "jose";
import fs from "node:fs/promises";
async function decryptJwe(token) {
const privateKeyPem = await fs.readFile("jwe-private.pem", "utf-8");
// Import private key for decryption
const privateKey = await importPKCS8(privateKeyPem, "RSA-OAEP-256");
const { plaintext, protectedHeader } = await compactDecrypt(token, privateKey);
const json = JSON.parse(new TextDecoder().decode(plaintext));
console.log("Header:", protectedHeader);
console.log("Decrypted payload:", json);
}
// Example usage: pass token as CLI arg
const token = process.argv[2];
decryptJwe(token).catch(console.error);
Here:
- Only the holder of
jwe-private.pemcan decrypt. - The payload is fully hidden during transit and at rest (wherever the JWE is stored).
JWT vs JWE in Practice
Let’s compare their roles in real systems.
JWT (JWS) – When Integrity Is Enough
Use JWT (JWS) when:
- You need identity and claims propagation between services
- The data is not highly sensitive (e.g., user ID, role, scope)
- You need stateless authentication without server-side session storage
- Multiple services need to read claims quickly without decryption
Common use cases:
- API access tokens
- Microservice-to-microservice identity propagation
- Browser or mobile authentication tokens (with secure storage)
JWE – When You Need Confidentiality
Use JWE when:
-
The payload contains sensitive data, e.g.:
- PII (personally identifiable information)
- Financial data
- Health-related data
-
You need end-to-end encryption of claims
-
Tokens may pass through untrusted infrastructure (proxies, logs, analytics tools)
Typical scenarios:
- Transmitting confidential data between microservices
- Storing encrypted claims that only specific services can read
- Protecting data flowing through user-controlled devices or networks
Example: JWT for API Authentication
This code shows a typical Express middleware verifying a JWT (JWS). No encryption here—just signature validation.
// auth-middleware.js
import { jwtVerify } from "jose";
const secretKey = new TextEncoder().encode(process.env.JWT_SECRET || "super-secret-key");
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.substring("Bearer ".length);
try {
const { payload } = await jwtVerify(token, secretKey, {
algorithms: ["HS256"],
audience: "my-api",
issuer: "https://auth.example.com"
});
// Attach user info to the request
req.user = {
id: payload.sub,
role: payload.role,
scopes: payload.scope
};
return next();
} catch (err) {
console.error("JWT verification failed:", err.message);
return res.status(401).json({ error: "Invalid or expired token" });
}
}
Here:
- The token is assumed to be a JWT (JWS).
- The API checks signature, issuer, and audience.
- Claims such as
roleandscopeare used for authorization.
Example: Nested JWT – JWS Inside JWE
For sensitive flows, a common pattern is:
- Sign the claims (JWS) → ensures integrity and authenticity.
- Encrypt the signed token (JWE) → ensures confidentiality.
Conceptually:
JWE( JWS( claims ) )
Signing and Then Encrypting
// nested-jwt.js
import { SignJWT, CompactEncrypt } from "jose";
import { importSPKI } from "jose";
import fs from "node:fs/promises";
async function createNestedToken() {
const hmacSecret = new TextEncoder().encode(process.env.JWT_SECRET || "super-secret-key");
const publicKeyPem = await fs.readFile("jwe-public.pem", "utf-8");
const publicKey = await importSPKI(publicKeyPem, "RSA-OAEP-256");
// 1. Create JWS (signed JWT)
const jws = await new SignJWT({
sub: "user_123",
email: "alice@example.com",
role: "premium",
iat: Math.floor(Date.now() / 1000)
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime("10m")
.sign(hmacSecret);
// 2. Encrypt the JWS as JWE
const payload = new TextEncoder().encode(jws);
const jwe = await new CompactEncrypt(payload)
.setProtectedHeader({
alg: "RSA-OAEP-256",
enc: "A256GCM",
typ: "JWE"
})
.encrypt(publicKey);
console.log("Nested token (JWE of JWS):", jwe);
}
createNestedToken().catch(console.error);
On the receiving side:
- Decrypt JWE with the private key → recover the JWS string.
- Verify JWS with the shared secret or public key (depending on algorithm).
This gives you:
- Integrity (from JWS)
- Authenticity (from JWS)
- Confidentiality (from JWE)
Security Best Practices for JWT and JWE
Whether you use JWT, JWE, or both, keep these in mind:
-
Never store secrets in JWT payloads
- JWT/JWS payloads are readable.
- Treat JWT as integrity, not privacy.
-
Use short expiration times (
exp)- Reduces the damage if a token is leaked.
- Short-lived access tokens + long-lived refresh tokens is a good pattern.
-
Pin algorithms explicitly
- When verifying, always specify allowed algorithms (e.g.,
["RS256"]). - This prevents algorithm confusion and
noneattacks.
- When verifying, always specify allowed algorithms (e.g.,
-
Use HTTPS everywhere
- Never send tokens over plain HTTP.
- Tokens are bearer credentials—whoever has them is “you”.
-
Rotate keys regularly
- Use key IDs (
kid) in headers. - Support multiple keys and rotate them safely.
- Use key IDs (
-
Store tokens securely
- In browsers, prefer HTTP-only, secure cookies instead of
localStorage. - On mobile, rely on secure OS-provided keychains or equivalent.
- In browsers, prefer HTTP-only, secure cookies instead of
-
Use JWE or nested tokens for sensitive data
- If the payload contains PII or something that would be damaging if leaked, encrypt it.
- Consider the nested pattern: JWS inside JWE.
Summary
-
JWT (typically JWS) focuses on integrity and authenticity of claims. The payload is readable, so avoid storing secrets.
-
JWE focuses on confidentiality, keeping the payload hidden via encryption.
-
Use JWT for:
- API authentication
- Stateless session tokens
- Microservice identity propagation when the data is not highly sensitive.
-
Use JWE (or nested JWS-in-JWE) for:
- Sensitive or regulated data
- End-to-end encrypted claims
- Flows where intermediaries must not read the payload
-
For high-security systems, combining JWS + JWE gives you integrity + authenticity + confidentiality in a single, robust pattern.
By understanding when to use JWT, when to use JWE, and how to combine them, you can design authentication and data-sharing mechanisms that are both practical and secure.
::contentReference[oaicite:0]{index=0}