JWT Signing and Verification: The Complete Developer Guide
Understand JSON Web Tokens (JWT) — how signing works with HS256, RS256, ES256, and EdDSA, common attacks (alg:none, key confusion), and best practices for secure implementation.
JSON Web Tokens (JWT) are the backbone of modern web authentication. Every time you log in to a SPA, call a REST API from a mobile app, or use single sign-on (SSO), a JWT is likely carrying your identity across the wire. Defined in RFC 7519 (2015), JWT is a compact, URL-safe token format that encodes claims (statements about a user or session) as a JSON object, then signs them so the receiver can verify they haven’t been tampered with.
But a JWT is only as secure as its implementation. Misconfigured algorithm validation, weak secrets, and missing expiration checks are among the most common vulnerabilities found in API penetration tests. This guide explains how JWT signing and verification work under the hood — covering every major algorithm family (HS256, RS256, ES256, EdDSA), the attacks that exploit sloppy implementations, and the best practices that production systems need.
Want to decode, sign, or verify a JWT right now? Try the JWT Signature Verifier tool — it supports Sign and Verify modes for HS256/384/512, RS256/384/512, PS256/384/512, and ES256/384/512, all in your browser.
What Is a JWT?
A JWT is an ASCII string composed of three Base64URL-encoded parts separated by dots:
header.payload.signature
Here’s a real example (the classic jwt.io sample token):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Let’s decode each part.
Header
The header declares the signing algorithm and token type:
{
"alg": "HS256",
"typ": "JWT"
}
alg— The cryptographic algorithm used to sign the token (e.g., HS256, RS256, ES256). This is the single most security-critical field in the JWT.typ— The token type, almost always “JWT”.kid(optional) — Key ID, used to select which key from a key set was used for signing. Important for key rotation.
The header is Base64URL-encoded to form the first part of the token.
Payload
The payload contains the claims — statements about an entity (typically the user) and additional metadata:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Claims come in three flavors:
- Registered claims — Predefined, interoperable claims recommended by RFC 7519:
iss(issuer),sub(subject),aud(audience),exp(expiration time),iat(issued at),nbf(not before),jti(JWT ID). - Public claims — Custom claims that should be registered with IANA or use collision-resistant namespaces to avoid name conflicts.
- Private claims — Custom claims agreed upon between the producer and consumer of the token (e.g.,
role,email,permissions).
Critical: The payload is Base64URL-encoded, not encrypted. Anyone who intercepts the token can read every claim. Never put passwords, API keys, or other secrets in a JWT payload.
Signature
The signature is computed over the encoded header and payload using the algorithm declared in the header:
For HS256 (symmetric):
HMAC-SHA256(base64url(header) + "." + base64url(payload), secret_key)
For RS256 (asymmetric):
RSA-SHA256-Sign(base64url(header) + "." + base64url(payload), private_key)
The signature guarantees two things:
- Integrity — The header and payload haven’t been modified since signing
- Authenticity — The token was signed by someone who holds the correct key
How JWT Signing Works by Algorithm Family
JWT supports a wide range of signing algorithms, grouped into four families:
HMAC Family (HS256, HS384, HS512) — Symmetric
HMAC algorithms use a shared secret — the same key is used to both sign and verify the token. This means any service that can verify a token can also create valid tokens.
How it works:
- Concatenate
base64url(header) + "." + base64url(payload) - Compute
HMAC-SHA256(concatenation, shared_secret) - Base64URL-encode the result as the signature
Pros: Fast, simple, low computational overhead. Cons: The shared secret must be distributed to every service that verifies tokens. If any one service is compromised, the attacker can forge tokens.
Best for: Monolithic applications, internal microservices where all parties are under the same administrative control.
Our HMAC Generator tool lets you experiment with the underlying HMAC-SHA256/384/512 operations.
RSA Family (RS256, RS384, RS512) — Asymmetric
RSA algorithms use a public/private key pair. The issuer signs with the private key; anyone can verify with the public key.
How it works:
- Concatenate
base64url(header) + "." + base64url(payload) - Hash the concatenation with SHA-256 (for RS256)
- Encrypt the hash with the issuer’s RSA private key to produce the signature
- Verifiers decrypt the signature with the RSA public key and compare the hash
Pros: Strong security boundary — the private key never leaves the signing service. Public keys can be freely distributed (via JWKS endpoints). Cons: Slower than HMAC; larger signatures (256 bytes for RSA-2048).
Best for: Federated identity, SSO, public APIs, microservices with separate issuer and verifier.
Our RSA Key Generator can produce the key pairs needed for RS256 signing.
RSA-PSS Family (PS256, PS384, PS512) — Asymmetric (Probabilistic)
RSA-PSS uses the same key pairs as RS256 but with a probabilistic signature scheme (Probabilistic Signature Scheme). The signature includes random padding, so signing the same message twice produces different signatures — unlike RS256, which is deterministic.
Pros: Stronger security proofs than PKCS#1 v1.5 (used by RS256); resistant to certain forgery attacks. Cons: Slightly more complex to implement; not all libraries support it.
Best for: New deployments that want the strongest RSA-based security.
ECDSA Family (ES256, ES384, ES512) — Asymmetric (Elliptic Curve)
ECDSA uses elliptic curve cryptography for signing. ES256 uses the P-256 curve with SHA-256, producing signatures of just 64 bytes — much smaller than RSA-2048’s 256 bytes at equivalent security.
Pros: Smaller keys (256 bits vs 2048 bits for RSA at 128-bit security), smaller signatures, faster signing. Cons: ECDSA requires a secure random nonce for each signature — nonce reuse leaks the private key (see our ECC guide for details).
Best for: Mobile apps, IoT, bandwidth-constrained environments.
Our ECC Key Generator and Ed25519 Sign/Verify tools cover the elliptic curve signing operations.
EdDSA (Ed25519) — Asymmetric (Modern)
EdDSA uses the Edwards curve Curve25519 with deterministic nonce generation, eliminating ECDSA’s nonce-reuse vulnerability. While not yet in the JWA standard (RFC 7518), "alg": "EdDSA" is defined in RFC 8037 and gaining adoption.
Pros: Fastest signing and verification, smallest keys (32 bytes), inherently constant-time (timing-attack resistant), no random nonce needed. Cons: Not yet universally supported in all JWT libraries.
Best for: Modern systems where library support is available.
Algorithm Comparison Table
| Algorithm | Type | Key size | Signature size | Speed | Use case |
|---|---|---|---|---|---|
| HS256 | Symmetric | 32+ bytes secret | 32 bytes | ⚡ Fastest | Internal APIs, monoliths |
| HS384 | Symmetric | 48+ bytes secret | 48 bytes | ⚡ Fast | Higher security margin |
| HS512 | Symmetric | 64+ bytes secret | 64 bytes | ⚡ Fast | Maximum HMAC security |
| RS256 | Asymmetric (RSA) | 2048+ bits | 256 bytes | 🐢 Slow signing | SSO, public APIs |
| RS384/512 | Asymmetric (RSA) | 2048+ bits | 256 bytes | 🐢 Slow | Same as RS256 |
| PS256 | Asymmetric (RSA-PSS) | 2048+ bits | 256 bytes | 🐢 Slow | Stronger RSA security |
| ES256 | Asymmetric (ECDSA) | P-256 (256 bits) | 64 bytes | 🏃 Moderate | Mobile, IoT |
| ES384 | Asymmetric (ECDSA) | P-384 (384 bits) | 96 bytes | 🏃 Moderate | Government, high-security |
| ES512 | Asymmetric (ECDSA) | P-521 (521 bits) | 132 bytes | 🏃 Moderate | TOP SECRET classification |
| EdDSA | Asymmetric (Ed25519) | 256 bits | 64 bytes | ⚡ Fast | Modern systems |
Decision framework:
- Same service signs and verifies → HS256
- Separate issuer and verifier → RS256 or ES256
- Need smallest tokens → ES256 or EdDSA
- Maximum compatibility → RS256 (supported everywhere)
JWT vs Traditional Sessions
| Feature | JWT | Traditional session |
|---|---|---|
| Server storage | Stateless — no server-side session data | Stateful — session stored in DB or cache (Redis) |
| Horizontal scaling | Trivial — any instance can verify tokens | Requires shared session store across instances |
| Revocation | Difficult — token valid until exp |
Immediate — delete the session from the store |
| Token size | 0.5–2 KB per request (carries claims) | 32–128 bytes (opaque session ID) |
| Client storage risk | Token contains readable claims — exposure = data leak | Opaque cookie — less info exposed |
| Renewal | Refresh token + rotation | Implicit renewal on each request |
| Cross-domain | Easy — token in Authorization header works anywhere | Complex — requires cookie domain configuration |
When to use JWT: Public APIs, microservices, SSO, mobile apps, third-party integrations. When to use sessions: Monolithic web apps with a single server or a small cluster behind a load balancer.
JWS vs JWE: Signing vs Encryption
JWT is actually an umbrella term for two distinct token types:
-
JWS (JSON Web Signature, RFC 7515) — The token is signed but the payload is readable (Base64URL-encoded, not encrypted). This is what most people mean by “JWT.” It provides integrity and authenticity, but not confidentiality.
-
JWE (JSON Web Encryption, RFC 7516) — The token is encrypted. The payload is hidden from anyone who doesn’t hold the decryption key. JWE provides confidentiality in addition to integrity and authenticity.
Rule of thumb: Use JWS by default. If you feel the need to put sensitive data in the payload, that’s usually a sign the data shouldn’t be in the token at all — keep identifiers and permissions in the payload, and store sensitive data server-side.
Common JWT Attacks
These are the vulnerabilities that appear most frequently in API penetration tests:
1. The alg: none Attack
The JWT spec (RFC 7519) allows "alg": "none" for unsigned tokens. If a verification library accepts this value, an attacker can strip the signature entirely and modify the payload at will:
{"alg": "none", "typ": "JWT"}.{"sub": "admin", "role": "superuser"}.
The third part (signature) is empty. If the server doesn’t reject alg: none, this token is accepted as valid.
Defense: Explicitly reject alg: none in your verification code. Configure an allowlist of accepted algorithms and never trust the alg field in the token header alone.
2. Algorithm Confusion (Key Confusion) Attack
If the server expects RS256 (asymmetric) but the library allows the attacker to switch to HS256 (symmetric), a devastating attack becomes possible:
- The attacker obtains the server’s RSA public key (often available via a JWKS endpoint)
- The attacker creates a JWT with
"alg": "HS256"and signs it using the RSA public key as the HMAC secret - The server, expecting RS256, sees HS256 in the header, uses the public key as an HMAC secret, and verifies the forged signature as valid
Defense: Never use the token’s alg header to select the verification algorithm. Configure your library to expect a specific algorithm (or allowlist) and reject mismatches.
3. Weak HMAC Secret
If the HS256 secret is short, predictable, or a common word (“secret”, “password123”, “key”), an attacker can capture a valid token and crack the secret offline using tools like hashcat or John the Ripper — often in minutes to hours.
Defense: Use secrets of at least 32 random bytes (256 bits). Generate with openssl rand -base64 32. Our JWT tool displays a yellow warning when the secret is shorter than the RFC 7518 recommended minimum.
4. kid (Key ID) Injection
The kid header field tells the server which key to use for verification. If the application uses kid unsafely — for example, in a SQL query or file path — an attacker can inject malicious values:
{"alg": "HS256", "kid": "../../../../dev/null"}
This can cause the server to verify the signature against an empty file or a predictable value, allowing token forgery.
Defense: Validate kid against a whitelist of known key IDs. Never use kid directly in database queries or file system paths.
5. Missing Expiration Check
Tokens without an exp claim (or with exp set far in the future) remain valid indefinitely. If an attacker steals such a token (via XSS, network interception, or log exposure), they have permanent access.
Defense: Always set exp to 15–60 minutes for access tokens. Use refresh tokens (stored in HttpOnly cookies) for longer sessions. Validate exp, iat, and nbf on every verification.
6. Cross-Service Token Confusion
If multiple applications share the same JWT issuer but don’t validate the aud (audience) claim, a token issued for one application can be replayed against another. An attacker who obtains a low-privilege token from App A can use it to access App B.
Defense: Always validate iss and aud against the values expected by your specific application.
7. Sensitive Data in the Payload
The JWT payload is Base64URL-encoded — trivially decoded by anyone who intercepts the token. Developers sometimes accidentally include passwords, API keys, PII, or other sensitive data in the payload.
Defense: Treat the JWT payload as public. Never include secrets or sensitive data. If confidentiality is needed, use JWE (encrypted JWT) or keep sensitive data server-side.
Security Best Practices
Algorithm Validation
- Explicitly specify the expected algorithm when verifying tokens. Never trust the
algin the JWT header. - Use an allowlist of accepted algorithms (
['RS256'],['HS256']). Reject everything else. - Never accept
alg: nonein production.
Example in Node.js:
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Explicit allowlist
audience: 'your-api', // Validate aud
issuer: 'your-auth-server', // Validate iss
});
Key Management
- HS256 secrets: At least 32 random bytes. Store in a secrets manager (AWS Secrets Manager, HashiCorp Vault). Never hardcode.
- RSA keys: Minimum 2048 bits; 3072 bits for new systems.
- ECDSA keys: Use P-256 for 128-bit security, P-384 for 192-bit.
- Key rotation: Use
kidto version keys. Support overlapping validity during rotation. Rotate at least every 90 days. - JWKS endpoints: Distribute public keys via JSON Web Key Set (JWKS) endpoints for automated key propagation.
Token Lifecycle
- Short
exp: 15–60 minutes for access tokens. - Refresh tokens: Longer-lived (days/weeks), stored in HttpOnly + Secure + SameSite=Strict cookies.
- Revocation: Maintain a token blacklist (Redis with TTL matching
exp) or use per-user version counters. - Client storage: Prefer HttpOnly cookies over localStorage (which is accessible to XSS).
Operational Security
- Don’t log JWTs. If you need debugging, log only
subandjti— never the full token. - Validate all claims:
exp,iat,nbf,iss,aud. Each one blocks an attack vector. - Use HTTPS. JWTs sent over HTTP are trivially intercepted.
- Penetration test. Include JWT-specific checks in your API security audits (OWASP API Security Top 10, category API2: Broken Authentication).
Code Examples
Browser — Web Crypto API (Verify HS256)
// Verify a JWT signature in the browser using Web Crypto API
async function verifyJWT(token, secret) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Import the secret key
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// Compute expected signature
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const sig = await crypto.subtle.sign('HMAC', key, data);
// Constant-time comparison
const expected = new Uint8Array(sig);
const actual = Uint8Array.from(atob(signatureB64), c => c.charCodeAt(0));
if (expected.length !== actual.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ actual[i];
return diff === 0;
}
Node.js — jsonwebtoken library
const jwt = require('jsonwebtoken');
// Sign a token (HS256)
const token = jwt.sign(
{ sub: '1234567890', name: 'John Doe', role: 'user' },
process.env.JWT_SECRET, // At least 32 random bytes
{ algorithm: 'HS256', expiresIn: '15m', issuer: 'my-app' }
);
// Verify a token (HS256)
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Explicit allowlist!
issuer: 'my-app',
audience: 'my-api',
});
console.log('Verified:', decoded);
} catch (err) {
console.error('Invalid token:', err.message);
}
// Sign a token (RS256)
const fs = require('fs');
const privateKey = fs.readFileSync('private-key.pem');
const rsToken = jwt.sign(
{ sub: '1234567890', role: 'admin' },
privateKey,
{ algorithm: 'RS256', expiresIn: '1h', keyid: 'key-2026-01' }
);
Python — PyJWT library
import jwt
import datetime
# Sign a token (HS256)
secret = "your-256-bit-secret-at-least-32-bytes"
token = jwt.encode(
{
"sub": "1234567890",
"name": "John Doe",
"role": "user",
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
"iat": datetime.datetime.utcnow(),
"iss": "my-app",
},
secret,
algorithm="HS256",
)
print(f"Token: {token}")
# Verify a token
try:
decoded = jwt.decode(
token,
secret,
algorithms=["HS256"], # Explicit allowlist!
issuer="my-app",
options={"verify_exp": True},
)
print(f"Decoded: {decoded}")
except jwt.InvalidTokenError as e:
print(f"Invalid: {e}")
# Verify an RS256 token
public_key = open("public-key.pem").read()
decoded = jwt.decode(
rs_token,
public_key,
algorithms=["RS256"],
issuer="my-app",
)
OpenSSL — Generate Keys for JWT Signing
# Generate RSA-2048 key pair for RS256
openssl genrsa -out private-key.pem 2048
openssl rsa -in private-key.pem -pubout -out public-key.pem
# Generate ECDSA P-256 key pair for ES256
openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem
openssl ec -in ec-private.pem -pubout -out ec-public.pem
# Generate Ed25519 key pair for EdDSA (OpenSSL 1.1.1+)
openssl genpkey -algorithm Ed25519 -out ed25519-private.pem
openssl pkey -in ed25519-private.pem -pubout -out ed25519-public.pem
# Generate a strong HMAC secret (32 random bytes, Base64-encoded)
openssl rand -base64 32
Troubleshooting Checklist
If your JWT verification is failing, check these common issues:
| Problem | Likely cause | Fix |
|---|---|---|
| “Signature verification failed” | Wrong secret or key | Ensure the secret/key matches what was used to sign. For RS256, use the correct public key. |
| “Token expired” | exp claim is in the past |
Issue tokens with a future exp. Check server clock synchronization (NTP). |
| “Invalid algorithm” | Algorithm mismatch | Ensure the verification algorithm matches the signing algorithm. Use an explicit allowlist. |
| Signature doesn’t match after decoding | Encoding mismatch | Ensure the token is transmitted as-is — no trimming, no URL decoding of Base64URL characters. |
| “Invalid audience” | aud claim mismatch |
The token was issued for a different service. Validate aud against your application’s identifier. |
| Key works locally but not in production | Key distribution issue | Use a JWKS endpoint to distribute public keys. Ensure kid matches across signing and verification. |
Frequently Asked Questions
Is JWT secure?
JWT itself is a well-designed standard (RFC 7519) with solid cryptographic foundations. The security problems come from implementation: accepting alg: none, using weak secrets, failing to validate exp/iss/aud, or storing tokens insecurely on the client. When implemented correctly with the best practices in this guide, JWT is secure for production use.
What is the difference between JWT and OAuth 2.0?
OAuth 2.0 is an authorization protocol — it defines how to obtain access tokens. JWT is a token format — it defines what the token looks like. OAuth 2.0 can use JWT as the access token format (and often does), but it can also use opaque tokens. OpenID Connect (the identity layer on top of OAuth 2.0) requires JWT for the id_token.
Should I use HS256 or RS256?
Use HS256 when the same service (or a tightly controlled cluster) both signs and verifies tokens — it’s faster and simpler. Use RS256 when the signer and verifier are separate services, when you need to distribute verification keys publicly (via JWKS), or when you need non-repudiation. For new projects without specific constraints, RS256 is the safer default.
How long should a JWT be valid?
Access tokens: 15–60 minutes. Short lifetimes limit the damage of token theft. Refresh tokens: Days to weeks, stored in HttpOnly + Secure + SameSite=Strict cookies, with rotation on each use. Never issue JWTs with no expiration.
Can I revoke a JWT before it expires?
JWTs are stateless by design, which makes revocation challenging. Three approaches:
- Token blacklist — Store revoked
jtivalues in Redis with TTL matching the token’s remainingexptime. - Per-user version counter — Store a version number in the database; reject tokens whose
iatpredates the current version. - Key rotation — In case of mass compromise, rotate the signing key and invalidate all existing tokens.
If you need instant, granular revocation, traditional sessions may be a better fit than JWT.
What is the difference between JWT and JWS and JWE?
JWT (JSON Web Token, RFC 7519) is the overall token format. JWS (JSON Web Signature, RFC 7515) is a signed but readable token — the most common JWT type. JWE (JSON Web Encryption, RFC 7516) is an encrypted token where the payload is hidden from anyone without the decryption key. When people say “JWT,” they usually mean JWS.
Where should I store a JWT on the client?
HttpOnly + Secure + SameSite=Strict cookie is the most secure option for web applications — it prevents JavaScript access (mitigating XSS token theft). localStorage is convenient but accessible to any JavaScript on the page — if your app has an XSS vulnerability, the token is compromised. For mobile apps, use the platform’s secure storage (Keychain on iOS, Keystore on Android).
Can JWT be encrypted?
Yes — that’s what JWE (JSON Web Encryption) does. A JWE token encrypts the payload so only the holder of the decryption key can read it. However, in most cases, if you need confidentiality, the data shouldn’t be in the token at all — keep it server-side and use JWS for authentication.
Related Tools
-
JWT Signature Verifier — Sign and verify JWTs with HS256/384/512, RS256/384/512, PS256/384/512, and ES256/384/512. Dual-mode Sign/Verify interface, all computed in your browser.
-
HMAC Generator — Generate and verify HMAC-SHA256/384/512 signatures. Useful for understanding the underlying operation behind HS256 JWT signing.
-
RSA Key Generator — Generate RSA key pairs (2048, 3072, 4096 bits) for RS256/PS256 JWT signing. Export in PEM/JWK format.
-
ECC Key Generator — Generate ECDSA key pairs on P-256, P-384, and P-521 for ES256/ES384/ES512 JWT signing.
-
Ed25519 Sign/Verify — Sign and verify with Ed25519 (EdDSA). While not yet standard in JWT libraries, EdDSA is the modern choice for digital signatures.
Further Reading
- RFC 7519 — JSON Web Token (JWT)
- RFC 7515 — JSON Web Signature (JWS)
- RFC 7516 — JSON Web Encryption (JWE)
- RFC 7518 — JSON Web Algorithms (JWA)
- RFC 8037 — CFRG Elliptic Curve ECDH and Signatures in JOSE (EdDSA)
- OWASP JSON Web Tokens Cheat Sheet
- PortSwigger Web Security Academy — JWT Algorithm Confusion
- RFC 8725 — JSON Web Token Best Current Practices
Related Articles
- HMAC Message Authentication — JWT’s HS256/HS384/HS512 algorithms are HMAC-based; understand the underlying primitive
- Elliptic Curve Cryptography (ECC) — JWT’s ES256/ES384 algorithms use ECDSA; learn how the curves and keys work
- How Digital Signatures Work — The cryptographic foundation behind all JWT signature algorithms