Why Signature Verification Matters
A JWT's header and payload are only Base64URL-encoded — anyone can read them, and anyone can construct a JWT with arbitrary claims. What makes JWTs useful for authentication is the signature: only someone holding the signing key can produce a signature that matches. When your server verifies the signature, you know the token was issued by a trusted party and has not been modified since.
Skipping verification — or verifying with the wrong algorithm — is one of the most common JWT vulnerabilities in the wild. Auditors routinely find production APIs that accept forged tokens because the code path did jwt.decode() withoutjwt.verify(), or accepted alg: none, or trusted the algorithm field from the header instead of pinning to an expected list.
The Three JWT Signature Algorithm Families
HS256, HS384, HS512 — HMAC (Symmetric)
HMAC (Hash-based Message Authentication Code) combines a shared secret with a hash function (SHA-256, SHA-384, or SHA-512). The same secret is used to sign and to verify. Fast, deterministic, small signatures (32/48/64 bytes). Downside: every verifier needs the shared secret, so compromise of any verifier compromises signing.
Use HS256 when: your signer and verifier are the same service or trust boundary (a monolith, a small set of trusted services with a shared config store).
RS256, RS384, RS512 — RSA (Asymmetric)
RSA uses a keypair: a private key held only by the issuer, and a public key that can be distributed freely. The issuer signs with the private key; anyone with the public key can verify. Larger signatures (256 bytes for RS256). Slightly slower than HS256 but still very fast (thousands of verifications per second).
Use RS256 when: the signer and verifier are separate services or the verifier does not control the signer (OIDC id_tokens from Google/Auth0/Cognito, cross-org integrations).
ES256, ES384 — ECDSA (Asymmetric, Smaller)
ECDSA (Elliptic Curve Digital Signature Algorithm) is the modern asymmetric choice. Same security level as RS256 with much smaller keys (256 bits) and signatures (64 bytes). Faster on the signer side, comparable on the verifier side. Recommended for new protocols; RSA remains prevalent for legacy reasons.
Verification Code by Language
Node.js with jose
import { jwtVerify, createRemoteJWKSet } from 'jose';
// HS256
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
});
// RS256 with JWKS (Auth0, Cognito, Google)
const JWKS = createRemoteJWKSet(
new URL('https://issuer/.well-known/jwks.json')
);
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'],
issuer: 'https://issuer',
audience: 'my-api',
});Python with PyJWT
import jwt
# HS256
payload = jwt.decode(
token,
key=SECRET,
algorithms=["HS256"],
)
# RS256 with public key
payload = jwt.decode(
token,
key=PUBLIC_KEY_PEM,
algorithms=["RS256"],
audience="my-api",
issuer="https://issuer",
)Go with golang-jwt
import "github.com/golang-jwt/jwt/v5"
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
// Verify the algorithm is what we expect
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
// reject
}Rust with jsonwebtoken
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
let validation = Validation::new(Algorithm::HS256);
let token_data = decode::<Claims>(
&token_str,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)?;What Verification Actually Does
The formal steps every JWT library follows:
- Split the token on
.into three segments. - Base64URL-decode the header, parse as JSON.
- Confirm the
algvalue is in the caller's allow-list. If not, reject immediately. - Reconstruct the signing input:
segments[0] + "." + segments[1]. - For HMAC: compute HMAC-SHA-x of signing input with secret. Constant-time compare against Base64URL-decoded
segments[2]. - For RSA/ECDSA: verify
segments[2]as a signature over signing input using the public key. - If signature is valid: also check
exp,nbf,iss,audclaims. - Return the payload only if every check passed.
Common Verification Failures & What They Mean
- "Invalid signature" — wrong key, wrong algorithm, or token tampered with. Never trust.
- "Algorithm mismatch" — token says HS256 but you expected RS256 (or vice versa). Attacker may be probing.
- "Token expired" — signature was valid but the
expclaim is in the past. Refresh the token. - "Audience does not match" — the token was issued for a different service. Might be a misconfiguration.
- "Issuer not trusted" — the
issclaim doesn't match the expected auth server. Reject. - "Unknown kid" — the JWT references a signing key not in the JWKS. Refresh the JWKS cache; if still missing, reject.
Verification Best Practices
- Always pin the algorithms option to an explicit allow-list. Never accept "none" or leave algorithms unspecified.
- Cache JWKS responses for 15–60 minutes with a forced refresh on kid miss.
- Use constant-time comparison for HMAC signatures to prevent timing attacks (all major libraries do this).
- Verify the signature BEFORE parsing or trusting any claim from the payload.
- Log verification failures with the token's
jtior a hash — never log the full token. - Set a small clock skew tolerance (30–60 seconds) for
expandnbfchecks.
Related JWT Tools
- JWT Decoder Online — decode-only visual walkthrough
- Decode JWT Token — language-agnostic decode guide
- JWT Decoder in Python — PyJWT verify patterns
- JWT Decoder in JavaScript — jose library verify examples
- Hash Generator — compute HMAC-SHA256 for JWT signatures locally