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.

· 19 min read · Authentication

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.

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:

  1. Integrity — The header and payload haven’t been modified since signing
  2. 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:

  1. Concatenate base64url(header) + "." + base64url(payload)
  2. Compute HMAC-SHA256(concatenation, shared_secret)
  3. 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:

  1. Concatenate base64url(header) + "." + base64url(payload)
  2. Hash the concatenation with SHA-256 (for RS256)
  3. Encrypt the hash with the issuer’s RSA private key to produce the signature
  4. 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:

  1. The attacker obtains the server’s RSA public key (often available via a JWKS endpoint)
  2. The attacker creates a JWT with "alg": "HS256" and signs it using the RSA public key as the HMAC secret
  3. 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

  1. Explicitly specify the expected algorithm when verifying tokens. Never trust the alg in the JWT header.
  2. Use an allowlist of accepted algorithms (['RS256'], ['HS256']). Reject everything else.
  3. Never accept alg: none in 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

  1. HS256 secrets: At least 32 random bytes. Store in a secrets manager (AWS Secrets Manager, HashiCorp Vault). Never hardcode.
  2. RSA keys: Minimum 2048 bits; 3072 bits for new systems.
  3. ECDSA keys: Use P-256 for 128-bit security, P-384 for 192-bit.
  4. Key rotation: Use kid to version keys. Support overlapping validity during rotation. Rotate at least every 90 days.
  5. JWKS endpoints: Distribute public keys via JSON Web Key Set (JWKS) endpoints for automated key propagation.

Token Lifecycle

  1. Short exp: 15–60 minutes for access tokens.
  2. Refresh tokens: Longer-lived (days/weeks), stored in HttpOnly + Secure + SameSite=Strict cookies.
  3. Revocation: Maintain a token blacklist (Redis with TTL matching exp) or use per-user version counters.
  4. Client storage: Prefer HttpOnly cookies over localStorage (which is accessible to XSS).

Operational Security

  1. Don’t log JWTs. If you need debugging, log only sub and jti — never the full token.
  2. Validate all claims: exp, iat, nbf, iss, aud. Each one blocks an attack vector.
  3. Use HTTPS. JWTs sent over HTTP are trivially intercepted.
  4. 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:

  1. Token blacklist — Store revoked jti values in Redis with TTL matching the token’s remaining exp time.
  2. Per-user version counter — Store a version number in the database; reject tokens whose iat predates the current version.
  3. 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.

  • 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

Related Tools