JWT signature verification is the step that makes the difference between authentication that actually works and authentication that can be bypassed. Libraries make it easy to skip steps or use permissive defaults. This guide walks through verification correctly, including the checks that developers most often leave out.
For debugging verification failures on tokens you are actively working with, the EvvyTools JWT Decoder lets you inspect the header, payload, and signature status without writing code. The longer guide on debugging JWT authentication errors covers the full range of failure modes.
What Verification Actually Does
A JWT has three parts: header, payload, and signature. The signature is computed by hashing the base64url-encoded header and payload together with a signing key. Verification recomputes that hash using the expected key and checks whether it matches the signature in the token.
If any part of the header or payload was changed after signing, the recomputed hash will not match. If the wrong key is used, it will not match. Verification failing means either the token is invalid or the wrong key was used.
Verification does not decrypt anything. The payload is signed, not encrypted. Anyone can read the claims in a JWT by base64-decoding the payload. Verification only proves that the claims have not been modified since signing.
Step 1: Install the Library and Choose Your Algorithm
The jsonwebtoken package from npm is the standard choice for Node.js.
npm install jsonwebtoken
Before writing any verification code, decide which algorithm you will support. The choice between HS256 and RS256 is the most important configuration decision:
- HS256 (HMAC-SHA256): Uses a shared secret. Both the issuer and the verifier hold the same key. Simpler to set up, but requires secure key distribution to every service that validates tokens.
- RS256 (RSA-SHA256): Uses a public/private key pair. The issuer signs with the private key; verifiers check with the public key. The private key stays with the issuer; the public key can be distributed freely.
RS256 is preferred for distributed systems because you can publish the public key without security risk. HS256 is acceptable for simple cases where the same codebase both issues and verifies tokens.
Step 2: Verify with the Algorithm Specified Explicitly
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET;
const EXPECTED_ISSUER = 'https://auth.yourapp.com';
const EXPECTED_AUDIENCE = 'https://api.yourapp.com';
function verifyToken(token) {
try {
const payload = jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'],
issuer: EXPECTED_ISSUER,
audience: EXPECTED_AUDIENCE,
});
return { valid: true, payload };
} catch (err) {
return { valid: false, error: err.message };
}
}
The critical detail is algorithms: ['HS256']. This hardcodes the expected algorithm in the verification options, not relying on the algorithm declared in the token header. If you pass an array of algorithms, only those will be accepted regardless of what the token says.
Never use algorithms: [] (empty) or omit the algorithms option entirely. Some versions of the library will then accept whatever the token header declares, including none, which means unsigned tokens pass verification.
Step 3: Add Issuer and Audience Validation
The issuer and audience options in the example above are not optional extras. They are the checks that prevent tokens intended for one service from being accepted by another.
// Without these, a token from staging can reach production,
// or a token for your user API can reach your admin API.
const options = {
algorithms: ['HS256'],
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
};
These values should come from environment configuration, not be hardcoded as strings, so they can differ between environments without code changes.
The RFC 7519 specification defines all the registered claims and their validation semantics. The iss and aud validation is standardized; every JWT library should implement it the same way when given the expected values.
Step 4: Handle Clock Skew With a Leeway Parameter
The clockTolerance option adds a grace period around expiry validation. This accounts for minor clock differences between the server that issued the token and the server verifying it.
const options = {
algorithms: ['HS256'],
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
clockTolerance: 10, // seconds
};
A tolerance of 5-10 seconds covers most real-world clock skew without meaningfully extending the effective expiry window. If you see intermittent "jwt not active" errors immediately after login, clock skew is the likely cause.

Photo by panumas nikhomkhai on Pexels
Step 5: Using RS256 With a Public Key
For RS256, replace the secret string with the public key:
const fs = require('fs');
const publicKey = fs.readFileSync('./keys/public.pem');
function verifyToken(token) {
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
});
return { valid: true, payload };
} catch (err) {
return { valid: false, error: err.message };
}
}
The public key can be a PEM-formatted string read from a file or fetched from the identity provider's JWKS endpoint. Identity providers that follow the OpenID Connect standard publish their public keys at a standard URL you can fetch dynamically.
For production services, fetching and caching the JWKS endpoint is preferable to managing static public key files, because key rotation at the identity provider automatically propagates to your verification logic.
Step 6: Build a Middleware Function
For Express.js APIs, wrap the verification in middleware:
function jwtMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.slice(7); // Remove 'Bearer '
const result = verifyToken(token);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
req.user = result.payload;
next();
}
app.use('/api', jwtMiddleware);
This middleware extracts the token from the Authorization header, verifies it, and attaches the decoded payload to req.user for downstream handlers. Authentication errors return 401 with a specific message rather than a generic server error.
Log the specific error from result.error on failures. This is what you will need when debugging, but strip the raw token from logs; only log the error message and the decoded claims you need (user ID, issuer, expiry).

Photo by Rafael Minguet Delgado on Pexels
Common Verification Errors and What They Mean
JsonWebTokenError: invalid signature - The key used for verification does not match the key used for signing. Check environment configuration for key mismatch.
TokenExpiredError: jwt expired - The exp claim is in the past. Check the token's expiry with a decoder and confirm the refresh token flow is working.
JsonWebTokenError: invalid algorithm - The algorithm in the token header is not in your allowed algorithms list. If you see this on a legitimate token, check whether the issuer changed algorithms.
NotBeforeError: jwt not active - The nbf claim is in the future on the verifying server. Check clock synchronization and add a clockTolerance value.
JsonWebTokenError: jwt audience invalid. expected: X - The aud claim does not match your expected audience. Check whether the client is requesting the correct audience from the token endpoint.
Paste a failing token into the EvvyTools JWT Decoder to see all the claims and the verification status at once. The OWASP Web Security Testing Guide includes JWT verification testing in its authentication section.
The Complete Verification Checklist
Before shipping JWT verification into production, confirm each of these:
- Algorithm is hardcoded in verification options, not read from the token header.
- Issuer validation is enabled and the expected value comes from environment config.
- Audience validation is enabled and the expected value is specific to this service.
- Clock tolerance is set for environments where skew is possible.
- Error messages returned to clients do not reveal internal details.
- Token claims are logged on failure; raw tokens are not.
- The verification key is loaded from environment config, not hardcoded in source.
EvvyTools has a free JWT Decoder and other developer tools useful for testing and debugging API authentication. For a broader look at JWT error patterns and how to diagnose them, the debugging guide covers the full range of failures including expiry, signature, issuer, audience, and malformed tokens.
Top comments (0)