HMAC, Signatures, and Common Verification Mistakes

HMAC, Signatures, and Common Verification Mistakes - article cover image Security
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

HMAC and digital signatures both let a party prove a message is authentic and unmodified, but they rely on different trust models and fail in different ways when implemented carelessly. HMAC is a symmetric construction: both the signer and the verifier share the same secret key, so anyone who can verify a tag could, in principle, also forge one. Digital signatures (RSA, ECDSA, EdDSA) are asymmetric: the signer holds a private key and the verifier only needs the corresponding public key, so verification never implies the ability to forge. Confusing these two models — or implementing either one with a subtly wrong comparison, algorithm check, or key-handling step — has produced some of the most impactful authentication bypasses in modern web and API security.

Unlike encryption bugs, which usually leak confidentiality, mistakes in MAC/signature verification usually break integrity and authentication directly: an attacker who can forge a valid signature or MAC can forge session tokens, API requests, license files, firmware images, or webhook payloads, all while the verifying system believes it is checking a cryptographically guaranteed property. This makes verification bugs disproportionately severe relative to how small the code mistake usually is — often a single wrong function call or a missing algorithm allow-list.

Attack Prerequisites

Exploiting a MAC/signature verification bug generally requires:

  • A verifier that accepts attacker-influenced input about how to verify — most commonly a JWT/JWS alg header the attacker controls, or a signature scheme that is not pinned to one specific algorithm.
  • A non-constant-time or otherwise leaky comparison between the computed and provided tag/signature, allowing byte-by-byte forgery via timing.
  • Access to at least one valid, previously-issued token/signature to study its structure, or to a public key when the target actually expects an HMAC secret (or vice versa) — the classic ‘confusion’ attacks.
  • A verification path that is reachable repeatedly without rate limiting, needed for timing-based or brute-force approaches.

How It Works

The single most consequential verification bug of the last decade is algorithm confusion in JWT: a JWT header declares its own signing algorithm (alg), and naive verification code uses that attacker-supplied value to decide *how* to verify rather than pinning the expected algorithm server-side. Two variants are common. First, alg: none — the JWT spec defines an unsigned variant, and a verifier that does not explicitly reject it will accept a token with an empty signature, letting an attacker forge arbitrary claims. Second, and more subtle, RS256-to-HS256 confusion: if an application normally verifies tokens with RSA public key P (algorithm RS256) but a naive JWT library, when it sees alg: HS256 in the header, uses whatever key material it was given as an *HMAC secret*, an attacker who knows the RSA public key (which is, definitionally, public) can craft an HS256 token HMAC-signed using that public key’s bytes as the HMAC secret. The server, told by the attacker’s own header to treat the key as an HMAC secret, computes the same HMAC and the forged token verifies.

A second broad category is non-constant-time comparison of MAC/signature values using a normal ==/memcmp/Object.equals, which returns as soon as it finds the first mismatched byte. The tiny timing difference between failing on byte 1 versus byte 16 is measurable over a network with enough samples (or trivially over a local/low-latency channel), letting an attacker recover a valid tag one byte at a time even though they cannot compute it directly — this is exactly the same *class* of oracle as a padding oracle, just against MAC comparison instead of padding validity.

A third recurring mistake is missing or incomplete verification coverage — checking a signature over only part of the message (e.g. verifying a JWT’s payload but not confirming the kid/iss/aud claims match the expected issuer/audience), or trusting a kid (key ID) header to look up a verification key from an attacker-influenced source (path traversal into a key file, or an SQL injection into a key lookup query), which again lets the attacker choose the very key used to validate their own forged signature.

Vulnerable Code / Configuration

The classic algorithm-confusion bug: verification code that trusts the token’s own alg header instead of pinning the expected algorithm:

import jwt   # PyJWT

# VULNERABLE: no algorithms= allow-list, so the token's own header
# picks the verification algorithm
def verify_token(token, rsa_public_key_pem):
    return jwt.decode(token, rsa_public_key_pem, options={'verify_signature': True})

# An attacker crafts a token with header {"alg": "HS256"} and body signed
# as HMAC-SHA256 using rsa_public_key_pem's raw bytes as the HMAC key.
# PyJWT, told to use HS256, treats the RSA public key text as an HMAC secret
# and the forged signature verifies.
Python

A non-constant-time tag comparison, which turns a correct MAC scheme into a timing oracle:

// VULNERABLE: '===' short-circuits on the first differing byte
function verifySignature(payload, providedSig, secret) {
  const expected = crypto.createHmac('sha256', secret)
                         .update(payload).digest('hex');
  return expected === providedSig;   // timing side channel
}
JavaScript

The corrected versions pin the algorithm explicitly and use a constant-time comparison:

def verify_token(token, rsa_public_key_pem):
    return jwt.decode(
        token, rsa_public_key_pem,
        algorithms=['RS256'],           # pin exactly one algorithm family
        options={'require': ['exp', 'iss', 'aud']},
        issuer=EXPECTED_ISSUER, audience=EXPECTED_AUDIENCE)
Python
const crypto = require('crypto');

function verifySignature(payload, providedSigHex, secret) {
  const expected = crypto.createHmac('sha256', secret).update(payload).digest();
  const provided = Buffer.from(providedSigHex, 'hex');
  if (expected.length !== provided.length) return false;
  return crypto.timingSafeEqual(expected, provided);  // constant-time
}
JavaScript

Walkthrough / Exploitation

Testing for JWT algorithm confusion starts with capturing a legitimate RS256 token and the server’s RSA public key (often published at a /.well-known/jwks.json endpoint, or extractable from a TLS cert / API docs). A tool like jwt_tool automates the RS256-to-HS256 downgrade attempt:

python3 jwt_tool.py <captured_jwt> -X k -pk rsa_public_key.pem
# -X k : key-confusion attack, signs a forged token using the RSA
#        public key bytes as an HMAC-SHA256 secret
Bash

jwt_tool outputs a forged token with alg: HS256 and a valid-looking HMAC signature computed over the public key; submitting it to the verification endpoint with modified claims (e.g. "role": "admin") confirms the bypass if the response reflects the elevated privilege:

curl -H "Authorization: Bearer <forged_hs256_token>" \
  https://target.example/api/whoami
Bash

For a timing side channel in a non-constant-time comparison, the attacker recovers the valid signature byte by byte, timing many repeated requests per candidate byte to average out network jitter:

import time, requests

def measure(sig_prefix):
    samples = []
    for _ in range(200):
        t0 = time.perf_counter()
        requests.post(url, json={'sig': sig_prefix + '0'*(64-len(sig_prefix))})
        samples.append(time.perf_counter() - t0)
    return min(samples)          # take the minimum to reduce jitter noise

# iteratively extend sig_prefix one hex nibble at a time, keeping whichever
# candidate yields a measurably larger average response time
Python

Note: ‘alg: none’ is the simplest JWT bug to test and the easiest to miss in review — many libraries reject it by default today, but custom/older JWT handling code and some non-JWT signed-token schemes still accept an empty or missing signature if the verifier does not explicitly check for one.

Opsec: Timing-based MAC forgery is noisy and slow over a real network — prefer it as a proof-of-concept against a local/lab instance of the same code, and treat network-based timing recovery as usually impractical within a normal assessment window unless request latency is unusually stable and low.

Detection and Defense

Correct verification is about closing off every degree of freedom the token/message format gives the attacker:

  • Pin the expected algorithm explicitly on every JWT/JWS/JWT-like verification call (algorithms=['RS256'], never derived from the token’s own header) and reject none unconditionally.
  • Never let attacker input select the key material class — if the server expects RSA/ECDSA public-key verification, never fall back to treating the same key bytes as an HMAC secret for any algorithm value.
  • Always use constant-time comparison for MAC/signature verification — hmac.compare_digest (Python), crypto.timingSafeEqual (Node), MessageDigest.isEqual (Java), never ==/memcmp/string equality.
  • Validate the full claim set, not just the signature — issuer, audience, expiry, not-before, and any kid/key-lookup value must be validated against an allow-list, never used directly to construct a file path or query.
  • Prefer vetted, actively maintained JWT/JOSE libraries configured with an explicit algorithm allow-list over hand-rolled token verification, and keep them patched — most major libraries have shipped a fix for at least one of these confusion classes historically.

Real-World Impact

JWT algorithm confusion (both alg: none and RS256-to-HS256 key confusion) has been documented and demonstrated against numerous JWT libraries and applications, prompting most major JOSE/JWT libraries to add mandatory algorithm allow-listing and to reject none by default; it remains a standard check in API and web application penetration tests whenever JWTs are used for session or service-to-service authentication. Non-constant-time comparison bugs are a similarly recurring finding in code review and are the reason essentially every major language’s standard or crypto library now ships an explicit constant-time comparison helper specifically to steer developers away from ==.

Conclusion

HMAC and digital signatures are only as strong as the verification logic wrapped around them — pinning the algorithm, never letting the message itself dictate how it should be checked, comparing tags in constant time, and validating the full claim set rather than the bare signature. Each of these is a small, mechanical rule, but skipping any one of them turns a cryptographically sound primitive into an authentication bypass that looks, to the verifying code, exactly like a legitimate, correctly signed message.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL