CWE-347: Improper Verification of Cryptographic Signature - JavaScript
Overview
Node.js services typically verify JWTs with the jsonwebtoken npm package. Two mistakes drive most CWE-347 findings in this ecosystem. First, calling jwt.verify() without an explicit algorithms option lets the token's own header pick the algorithm. Second, using jwt.decode() where jwt.verify() belongs: decode() parses and returns the payload without checking the signature at all, so any code path that authorizes a request based on jwt.decode() output is trusting unverified data.
The first of those has changed shape, and the version everyone quotes is out of date. Measured on jsonwebtoken 9.0.3, the library already refuses alg: none when a key is supplied (jwt signature is required) and already refuses an RS256 token re-signed as HS256 against the RSA public key (invalid algorithm) - it derives the permitted family from the key type, which is what 9.0.0 changed. What is still open without the pin is cross-algorithm acceptance within a family: a token minted as HS512 verifies against a deployment that only ever issues HS256, and an RS512 or PS256 token verifies against an RS256 deployment's public key. So the pin still matters, but for a narrower reason - it makes "the signature verified" mean "this token was issued the way we issue tokens". If you are on jsonwebtoken 8.x or an older library, the classic bypasses are live and the pin is the only thing stopping them.
Webhook and API signature verification (Stripe-, GitHub-, and similar HMAC-signed payloads) is vulnerable when the computed digest is compared with === or Buffer.equals() instead of a constant-time function.
The safe replacements are: always pass a hardcoded algorithms array to jwt.verify(), never substitute jwt.decode() for authorization, and use crypto.timingSafeEqual() for any raw signature or HMAC comparison.
Common Vulnerable Patterns
jwt.verify() Without an Algorithms Allowlist
const jwt = require('jsonwebtoken');
// VULNERABLE - no algorithms restriction; the token header picks the algorithm
function verifyAccessToken(token, rsaPublicKey) {
return jwt.verify(token, rsaPublicKey);
}
// Attack: take a legitimate RS256 token, change the header to {"alg":"RS512"}
// (or PS256), and re-sign it with the same key. verify() accepts it, because
// rsaPublicKey is valid for the whole RSA family and nothing said which member
// of it this deployment issues.
//
// The same call with a shared secret is the version people actually meet: a
// token minted as HS512 verifies against a service that only ever issues HS256.
Why this is vulnerable: jwt.verify()'s second argument is just key material; without options.algorithms, the library narrows the acceptable algorithms to the ones that key type supports and then lets the token header choose among them. Since 9.0.0 that narrowing is real - measured on 9.0.3, an RS256 token re-signed as HS256 with the RSA public key as the secret is refused with invalid algorithm, and an alg: none token with jwt signature is required. What remains is the choice inside the family, and that is enough for a token your issuer would never mint to be treated as one it did. Which member of the family a token used is not a detail the application can see afterwards unless it pinned one.
Authorizing on jwt.decode() Instead of jwt.verify()
const jwt = require('jsonwebtoken');
// VULNERABLE - decode() does not check the signature at all
function getUserFromToken(token) {
const payload = jwt.decode(token);
return payload; // caller trusts payload.sub, payload.role, etc.
}
// Attack: craft any JWT with an arbitrary payload and no valid signature.
// decode() returns it unchanged; nothing here ever checked who signed it.
Why this is vulnerable: jwt.decode() exists to inspect claims without verification (for example, reading kid from the header before looking up a key) - it is explicitly not an authentication check. Using its output to make an authorization decision is equivalent to trusting client-supplied JSON.
Naive Equality for Webhook Signature Comparison
const crypto = require('crypto');
// VULNERABLE - === is not constant-time; it can return as soon as it finds
// the first differing character
function verifyWebhookSignature(requestBody, signatureHeader, webhookSecret) {
const expected = crypto.createHmac('sha256', webhookSecret).update(requestBody).digest('hex');
return expected === signatureHeader;
}
Why this is vulnerable: === returns as soon as it finds a difference, so how long the comparison takes depends on how much of the submitted signature matched. What that leaks is coarser than the familiar description of it: V8 compares a machine word at a time rather than a character, so the entire spread between a first-byte and a last-byte mismatch is a couple of nanoseconds and two nearby positions are not separable at all - CWE-208 carries the measurements. It is still the defect to fix. The length check leaks cleanly whatever the block width is, that width is an unspecified implementation detail that a runtime upgrade can change, an attacker on the same host measures far more finely than one across a network, and the fix is a single function call.
Secure Patterns
Pin the Algorithm Allowlist
const jwt = require('jsonwebtoken');
// SECURE - algorithm is pinned; the token header cannot select RS512 or PS256
function verifyAccessToken(token, rsaPublicKey) {
return jwt.verify(token, rsaPublicKey, {
algorithms: ['RS256'],
issuer: 'https://issuer.example.com',
audience: 'my-api',
});
}
Why this works: options.algorithms is checked by jsonwebtoken independently of the token header - any token whose header names an algorithm outside the array is rejected with invalid algorithm before signature verification runs. Verified on 9.0.3: the same key that accepts an RS512 and a PS256 token without the option refuses both with it, and the legitimate RS256 token is still accepted. Because the array is a hardcoded literal, there is no path for attacker-controlled input to widen it at runtime.
Never Substitute decode() for verify()
const jwt = require('jsonwebtoken');
// SECURE - the only path from a token to trusted claims is verify()
function getUserFromToken(token, rsaPublicKey) {
const claims = jwt.verify(token, rsaPublicKey, { algorithms: ['RS256'] });
return claims; // signature has been checked before these claims are used
}
// jwt.decode() is only acceptable when its output is used to look up a key
// (e.g. resolving `kid`) and is never itself the basis for authorization -
// and even then, the key resolved must still pass through verify().
function resolveSigningKey(token) {
const unverifiedHeader = jwt.decode(token, { complete: true })?.header;
return trustedKeyStore.getKeyById(unverifiedHeader?.kid); // lookup only, not trust
}
Why this works: Every claim the application acts on comes from jwt.verify()'s return value, which is only produced after signature and algorithm checks pass. jwt.decode() is confined to the narrow, safe use case of reading the header to select a key - the resolved key is then still passed through verify(), so an attacker cannot use a forged kid to make an untrusted key get trusted.
kid is the only header parameter safe to read this way, and only because it is used as an index into trustedKeyStore, which the application populated. jku, x5u, jwk and x5c look like the same idea and are not: they name where the key comes from, so honouring one lets the sender hand you a key pair they generated and have their own token verify against it. jku and x5u are worse again, because resolving them means the verification path issues an outbound HTTP request to a URL in an unauthenticated token (CWE-918). If you use a JWKS client such as jwks-rsa, configure it with a fixed jwksUri and leave jku unread.
Constant-Time Comparison for Webhook HMAC Signatures
const crypto = require('crypto');
// SECURE - webhook HMAC-SHA256 verification with constant-time comparison
function verifyWebhookSignature(requestBody, signatureHeader, webhookSecret) {
const expected = crypto.createHmac('sha256', webhookSecret).update(requestBody).digest();
const provided = Buffer.from(signatureHeader, 'hex');
return expected.length === provided.length && crypto.timingSafeEqual(expected, provided);
}
// The handler below requires this file as './webhook-signature'
module.exports = { verifyWebhookSignature };
Why this works: crypto.timingSafeEqual() compares the full contents of both buffers in constant time, removing the content-dependent timing signal that === or Buffer.equals() leaks. It throws if the buffers have different lengths rather than returning false, so the length check must happen first - checking expected.length === provided.length before calling it avoids that exception while still failing closed on a length mismatch.
Framework-Specific Guidance
Express: Verify Against the Raw Body, Not the Parsed JSON
const express = require('express');
const crypto = require('crypto');
// The verifier from the section above, as its own module
const { verifyWebhookSignature } = require('./webhook-signature');
const app = express();
// SECURE - capture the raw request body for signature verification before
// any JSON parsing/re-serialization can change the byte sequence that was signed
app.post(
'/webhooks/provider',
express.raw({ type: 'application/json' }),
(req, res) => {
const signatureHeader = req.get('X-Signature');
if (!verifyWebhookSignature(req.body, signatureHeader, webhookSecret)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body);
// process payload
res.status(200).end();
}
);
Why this works: express.raw() gives the handler the exact bytes the client sent, before Express's JSON body parser decodes and re-encodes them. HMAC verification must run against those exact bytes - decoding first (with express.json()) and re-serializing the object for verification can produce a different byte sequence than what the sender actually signed, causing either false rejections of legitimate requests or, if the re-serialization happens to match, a verification step that isn't actually checking the bytes that get processed.
Considerations
This is one of the few findings that is almost never a false positive. For most weaknesses the first question is whether the value is security-relevant; here, if a signature is being checked at all, something is trusting the result. The narrow exception is data that never crossed a trust boundary - a token your own process minted, held in memory, and verified moments later. If the token arrived over the network, the check matters.
Decide where verification happens, and whether once is enough. A gateway that verifies tokens before forwarding lets backend services skip the work, which is efficient and fine until one service becomes reachable another way - an internal caller, a service mesh retry, a debugging port. Verifying again in the service costs little and does not depend on network topology staying as drawn. If you do rely on the gateway, make it impossible to bypass rather than merely inconvenient.
Symmetric algorithms give every verifier the power to mint. HS256 uses one shared secret, so any service holding it to check tokens can also issue them. With three services and one secret you have three places a forged administrator token can come from. RS256 and EdDSA split that: the issuer holds the private key, verifiers hold only the public one. If more than one service verifies, that separation is worth the extra key management.
clockTolerance and maxAge do different jobs. clockTolerance forgives
skew between machines; maxAge caps how old a token may be regardless of what
exp claims, which protects you against an issuer that mints long-lived tokens
by mistake. Setting the first does not give you the second.
Key rotation needs a cache policy decided in advance. Resolving keys by
kid from a JWKS endpoint means an outbound fetch on the verification path.
Cache too briefly and every request becomes a network call, so an issuer outage
takes your authentication down with it; cache too long and a rotated-away key
stays trusted. Cache by kid with a refresh on unknown values, plus a floor on
how often that refresh can fire, so an attacker cannot drive fetches by sending
tokens with random kid values.
Expiry is not revocation. Signature verification proves a token was issued and unmodified; it says nothing about whether the account was disabled a minute ago. Short lifetimes narrow that window and cost a refresh round trip; a revocation list closes it and costs a lookup on every request. Which you need depends on how quickly access must actually stop - "immediately" and "within fifteen minutes" are different systems.
Testing
- Normal: a legitimately issued RS256 token and a correctly HMAC-signed webhook payload are both accepted.
- Boundary: a token signed by a
kidnot present in the trusted keystore, and a signature header with an odd-length or non-hex value, are both rejected without throwing an unhandled exception.Buffer.from('zzzz', 'hex')returns a zero-length buffer rather than throwing, which the length guard then turns intofalse. - Malicious - cross-algorithm: re-sign a valid RS256 token as RS512 with the same key (or, on a symmetric deployment, mint an HS512 token with the same secret).
jwt.verify()must throwJsonWebTokenError: invalid algorithm. This is the assertion that moves when thealgorithmspin is added, so it is the one that proves the fix. - Malicious - algorithm confusion: re-sign a valid RS256 token as HS256 using the server's known RSA public key as the HMAC secret;
jwt.verify()must throwJsonWebTokenError. Onjsonwebtoken9.x this passes before the fix as well - it confirms the library's behaviour, not your configuration - so keep it for the regression value and do not read it as evidence the pin is in place. - Malicious - alg=none: submit a token with header
{"alg":"none"}and an empty signature segment;verify()must reject it. As above, 9.x rejects this withjwt signature is requiredwhether or notalgorithmsis set. - Malicious - decode() misuse: confirm no code path grants access, roles, or trust based solely on
jwt.decode()output. - Malicious - tampered webhook payload: flip one byte in the raw request body while keeping the original signature header;
verifyWebhookSignature()must returnfalse.
Common Pitfalls
- Setting
algorithmson someverify()calls but not others: codebases with more than one JWT-consuming module (an API gateway path alongside a websocket auth handler, for example) sometimes fix the flagged call site and miss a sibling. Grep forjwt.verify(across the codebase, not just the finding's line. - Verifying the parsed JSON body instead of the raw bytes: even with
crypto.timingSafeEqual()in place, computing the HMAC overJSON.stringify(req.body)instead of the original request bytes can pass verification against a re-serialized payload that differs from what the sender actually signed, or fail verification for legitimate requests whose original formatting doesn't survive a parse/stringify round trip. - Treating
jwt.decode(token, { complete: true })as a lightweight verification shortcut:complete: truereturns the header, payload, and signature together, which can look like a more thorough check than plaindecode()- it still never verifies the signature.
Dependencies and Installation
jsonwebtoken(npm) - keep at a current maintained version. 9.0.0 is the line that matters here: from that release the library derives the permitted algorithm family from the key type, which is what closedalg: noneand RS256-as-HS256 without a pin. On 8.x and earlier both are live (CVE-2015-9235 is thealg: noneone), so check the installed version before deciding what a missingalgorithmsoption costs you.cryptois part of the Node.js standard library; no additional package is needed fortimingSafeEqual().
Migration Considerations
Adding an explicit algorithms allowlist will reject any previously accepted token signed with an algorithm being removed. The case that catches people is not a deliberate second algorithm but an accidental one: an issuer library upgraded to a new default, a second issuer configured with RS512, or a signing service that was always minting HS384 while everyone assumed HS256. Read the alg header off a sample of live tokens - jwt.decode(token, { complete: true }).header.alg - rather than reading the issuer's configuration, then narrow to what you actually see. Expect active sessions signed under a now-rejected algorithm to require re-authentication.