Cybersecurity Application Security

Timing Attacks on Token Comparison: Fixing Non-Constant-Time Auth Checks

July 05, 2026 11 min read 3 views

Your webhook handler validates the X-Hub-Signature-256 header with a simple == check, your HMAC comparison uses str1 === str2, and your API key validation runs through a standard equality test. Everything looks correct β€” but an attacker sitting on the network, or even across the public internet, may be able to forge those tokens without ever knowing the secret. That's a timing attack.

The fix is a one-line change in most languages, but you have to know where to look. This article walks through exactly how the attack works, why ordinary string equality enables it, and how to eliminate the vulnerability across Python, Node.js, Go, Java, and PHP.

What You'll Learn

  • How timing side-channel attacks exploit early-exit string comparison
  • Why HMAC verification, API keys, and session tokens are all at risk
  • The constant-time comparison function to use in each major language
  • Where these vulnerabilities hide in real codebases beyond the obvious spots
  • How to verify your fix actually behaves in constant time

Prerequisites

You should be comfortable reading code in at least one backend language. A basic understanding of HMAC and how secret-based token verification works will help, but is not required β€” the relevant concepts are explained inline.

What a Timing Attack Actually Is

A timing attack is a type of side-channel attack. Instead of breaking the cryptography directly, the attacker observes how long an operation takes and uses that information to infer secret data. The channel carrying the information is time, not the response body or error message.

In authentication, the classic victim is string comparison. When you compare two byte strings and return as soon as you find a mismatch, the time the comparison takes leaks how many bytes matched before the mismatch. An attacker who can send many requests and measure response times can use that signal to guess the secret one character at a time.

This is not a theoretical exercise. Researchers have demonstrated practical timing attacks over local networks, and improvements in statistical techniques and infrastructure (low-jitter cloud regions, co-located VMs) have extended the attack surface further than most engineers expect.

Why Short-Circuit Evaluation Is the Problem

Every language runtime optimizes string comparison for speed. The standard approach is to iterate through both strings and return false the moment any byte differs. This is great for performance and terrible for security when one of the strings is a secret.

Consider comparing an expected HMAC like a3f... against an attacker-supplied value. If the attacker sends b000...000, the comparison fails immediately on byte zero. If they send a000...000, it passes byte zero and fails on byte one, taking fractionally longer. By repeating this thousands of times for each byte position and averaging the times, the attacker can statistically determine each correct byte in sequence. The secret is recovered without ever cracking the cryptographic primitive.

The math works because the attacker controls both the input and the timing measurement. They don't need microsecond precision on a single request β€” they need statistical confidence over many requests, which is achievable even over the public internet with enough samples and a stable baseline.

How Attackers Measure the Difference

The timing differences in string comparison are in the nanosecond-to-microsecond range. Network jitter is in the millisecond range. So how does this work in practice?

The attacker sends each candidate value thousands of times and takes the median (not mean, because outliers from garbage collection or network spikes distort the mean). Across enough samples, even a sub-microsecond difference in CPU time becomes statistically detectable. Research papers on remote timing attacks have shown reliable exploitation over LAN with a few thousand requests per byte, and over WAN with hundreds of thousands β€” which is feasible in an automated script over minutes or hours.

For a target that has no rate limiting on its token-checking endpoint, this is a realistic attack. If you're not familiar with how easily unprotected endpoints get probed, take a look at common JWT validation mistakes that let attackers forge tokens β€” the attacker motivation is identical.

The Vulnerable Comparison Pattern

Here is the pattern you'll find in countless codebases, across many languages:

# Python β€” vulnerable
import hmac, hashlib

def verify_webhook(secret: bytes, body: bytes, received_sig: str) -> bool:
    expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
    return expected == received_sig  # <-- early-exit comparison
// Node.js β€” vulnerable
const crypto = require('crypto');

function verifyWebhook(secret, body, receivedSig) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return expected === receivedSig; // <-- early-exit comparison
}

Both examples look correct at a glance. The HMAC computation itself is fine β€” SHA-256 is not broken. The vulnerability is the final equality test. The == and === operators use short-circuit logic, and that's all the attacker needs.

Fixing It: Constant-Time Comparison in Every Major Language

The solution is a function that always reads every byte of both inputs before returning, regardless of where the first mismatch occurs. The comparison time becomes a function of the string length, not the position of the first differing byte. These functions exist in every major standard library β€” use them.

Python

Python's hmac module has included compare_digest since Python 3.3. It is the correct tool for any secret comparison in Python code.

import hmac, hashlib

def verify_webhook(secret: bytes, body: bytes, received_sig: str) -> bool:
    expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
    # compare_digest accepts str/str or bytes/bytes β€” types must match
    return hmac.compare_digest(expected, received_sig)

One gotcha: both arguments must be the same type. Passing a bytes expected value against a str received value raises a TypeError. Make sure you normalize the type (both hex strings, or both raw bytes) before calling compare_digest.

Node.js

Node's crypto module exposes timingSafeEqual, which operates on Buffer or TypedArray objects. You need to convert your strings to buffers first, and both buffers must be the same length β€” otherwise the function throws. Check the length first to avoid leaking length information through that error path.

const crypto = require('crypto');

function verifyWebhook(secret, body, receivedSig) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  const expectedBuf = Buffer.from(expected, 'utf8');
  const receivedBuf = Buffer.from(receivedSig, 'utf8');

  // Length check must not short-circuit: compare lengths separately
  if (expectedBuf.length !== receivedBuf.length) {
    return false;
  }

  return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}

The length check itself does leak whether the received value has the correct length β€” but since HMAC-SHA256 hex output is always 64 characters, that's not secret information for a well-defined protocol. If you are comparing variable-length secrets (API keys with user-chosen lengths), consider padding to a fixed length before comparing.

Go

Go's crypto/subtle package provides ConstantTimeCompare, which takes two byte slices and returns an integer (1 for equal, 0 for not equal) rather than a boolean. The integer return is intentional β€” it makes it harder to accidentally use the result in a way that reintroduces a branch.

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
)

func verifyWebhook(secret, body []byte, receivedSig string) bool {
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    return subtle.ConstantTimeCompare(
        []byte(expected),
        []byte(receivedSig),
    ) == 1
}

Go's crypto/hmac package also exposes an Equal function that wraps ConstantTimeCompare for the common case of comparing two raw HMAC byte slices directly. If you're working with raw bytes rather than hex strings, prefer hmac.Equal(expected, received).

Java

Java's MessageDigest.isEqual method performs a constant-time comparison. It has been available since Java 6. For HMAC-based checks, compute both MACs as byte arrays, then compare with isEqual.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

public boolean verifyWebhook(byte[] secret, byte[] body, byte[] receivedSig) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret, "HmacSHA256"));
    byte[] expected = mac.doFinal(body);
    return MessageDigest.isEqual(expected, receivedSig);
}

PHP

PHP provides hash_equals() since PHP 5.6. Pass both values as strings. The function returns false immediately if the lengths differ, but since you control the expected string length (it's always the HMAC output length), this does not leak secret information.

<?php
function verifyWebhook(string $secret, string $body, string $receivedSig): bool {
    $expected = hash_hmac('sha256', $body, $secret);
    return hash_equals($expected, $receivedSig);
}

Where Timing Vulnerabilities Hide in Real Codebases

Most developers patch the obvious HMAC check and consider the job done. The vulnerability is usually in several other places too.

Webhook Signature Validation

Every major SaaS platform β€” Stripe, GitHub, Shopify, Twilio β€” sends a signature header with each webhook. Your receiver computes the expected signature and compares. This is the canonical timing-attack surface. Use constant-time comparison here without exception. The comparison happens on an unauthenticated endpoint that any party can hit at will.

API Key Lookups

Many services accept an API key in a header and look it up in a database. The naive approach fetches the stored key and compares with ==. Even if you switch to constant-time comparison for the final equality check, watch out for lookup logic that returns early when no record is found (leaking whether any key with that prefix exists). A more robust approach: hash the incoming key with a fast HMAC using a server-side secret before querying, so the attacker never learns whether individual bytes match any stored key. For a deeper look at authentication edge cases, the OAuth 2.0 misconfiguration patterns around redirect URIs article covers similar reasoning about what information you inadvertently expose during auth flows.

Password Reset Token Checks

Password reset tokens are high-value targets. They're short-lived, but the comparison endpoint is unauthenticated and often has generous rate limits (to avoid locking out legitimate users). If your framework's built-in token comparison uses ==, replace it with a constant-time function. Django's constant_time_compare from django.utils.crypto is a good drop-in if you're in that ecosystem.

Session Token Comparison

Session middleware that validates tokens server-side (as opposed to JWTs validated cryptographically) often does a direct string equality check against the session store. If your session store is Redis and you fetch the stored token to compare in application code, that application-level comparison needs to be constant-time. This is less commonly exploited than webhook endpoints because session tokens change per-user, but it's still worth fixing.

Similarly, if you're building custom API security middleware, the kinds of subtle bugs described in CORS misconfiguration patterns show how easily auth-adjacent code has unintended trust boundaries β€” timing vulnerabilities follow the same pattern of small decisions with large security impact.

Testing Your Fix: Verifying Constant-Time Behavior

You can't unit-test constant-time behavior with ordinary assertions β€” you need to measure timing empirically. The goal is to confirm that comparison time does not correlate with prefix match length.

A basic script approach: generate an expected HMAC, then time comparisons against inputs that share 0 bytes, 16 bytes, 32 bytes, and all 64 bytes (one wrong final byte). Run each comparison ten thousand times, take the median, and check that the medians are within measurement noise of each other.

import hmac, hashlib, time, statistics

secret = b"supersecret"
body = b"payload"
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()

def time_comparison(candidate: str, iterations: int = 10_000) -> float:
    times = []
    for _ in range(iterations):
        start = time.perf_counter_ns()
        hmac.compare_digest(expected, candidate)
        times.append(time.perf_counter_ns() - start)
    return statistics.median(times)

results = {
    "0 bytes match":  time_comparison("x" * 64),
    "32 bytes match": time_comparison(expected[:32] + "x" * 32),
    "63 bytes match": time_comparison(expected[:63] + "x"),
}

for label, ns in results.items():
    print(f"{label}: {ns:.1f} ns")

On a modern machine, all three should produce nearly identical median times. If you accidentally left a == in place, you will see a clear correlation between match prefix length and timing. Run this test as a sanity check after any refactor of your auth comparison code.

For a comparison with other subtle injection-class bugs that only reveal themselves under specific conditions, SQL injection hidden inside ORM-heavy codebases follows a similar theme: the vulnerability is invisible in normal operation but exploitable by a patient, methodical attacker.

Common Pitfalls When Adding Constant-Time Checks

  • Type mismatch: Most constant-time functions require both arguments to be the same type (both str, both bytes). A TypeError at runtime may default to returning False in a try-except wrapper, which is correct behavior but hides a bug you should fix explicitly.
  • Length check before comparison: If you exit early when lengths differ, you leak whether the attacker's input has the right length. For fixed-length tokens like HMAC digests this is harmless. For variable-length secrets, avoid the early exit or pad to a fixed length first.
  • Logging the comparison result: Don't log whether a comparison passed or failed in a way that an attacker can correlate with their probes. Log at a higher level (request-level pass/fail) not at the byte-comparison level.
  • Re-encoding issues: If the expected token is bytes and the received value arrives as a URL-encoded or base64-encoded string, decode it before comparing. Comparing a hex string to raw bytes with compare_digest will always return False, not an error, which can mask bugs in your test suite.
  • Framework auto-comparison: If you're using a framework that provides a built-in HMAC verification utility (Flask-HMAC, Express middleware, etc.), verify in the source code that it uses a constant-time comparison. Don't assume β€” check. Framework maintainers have shipped this bug before.

Timing bugs share a trait with insecure direct object reference vulnerabilities: they look completely innocuous in a code review because the logic is correct, and they only become dangerous in the context of how an attacker controls the inputs and observations.

Wrapping Up

Timing attacks are one of the few vulnerabilities where the fix is genuinely simple, but only if you know the fix exists. A one-line change from == to hmac.compare_digest (or the equivalent in your language) eliminates a real attack vector with zero performance impact.

Here are the concrete actions to take right now:

  1. Grep your codebase for HMAC verification, API key comparison, webhook signature validation, and password reset token checks. Search for == near any variable named token, sig, signature, hmac, or key.
  2. Replace each equality check with the constant-time comparison function for your language: hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js), subtle.ConstantTimeCompare (Go), MessageDigest.isEqual (Java), hash_equals (PHP).
  3. Run the timing sanity-check script from the section above on your actual comparison code, not a toy example, to confirm the fix behaves as expected.
  4. Add a code review checklist item that flags any equality operator used to compare a string whose value is derived from a secret or acts as a bearer credential.
  5. Check third-party middleware and SDK code you rely on for webhook or token validation β€” read the source, don't trust the readme.

Frequently Asked Questions

Can timing attacks work over the public internet with all the network jitter?

Yes, though they require more samples. Attackers take thousands of measurements per byte position and use statistical methods like median timing to filter out jitter. Research has demonstrated successful remote timing attacks over the internet with automated scripts running for minutes to hours.

Does using HTTPS protect against timing attacks on token comparison?

No. TLS encrypts the content of requests and responses, but the attacker is measuring elapsed time from sending the request to receiving the response β€” that timing information is available regardless of whether the connection is encrypted.

Is it enough to use a constant-time comparison function, or do I need to do more?

Constant-time comparison eliminates the timing leak in the comparison itself, but you should also apply rate limiting to endpoints that accept tokens. Without rate limiting, an attacker can still collect the thousands of samples needed for a statistical timing attack, just more slowly.

Why doesn't hashing both strings before comparing them with == solve the problem?

Hashing both values before comparison does not help if the hash function itself uses early-exit comparison internally, or if you compare the hash outputs with a non-constant-time operator. The only correct fix is to use a purpose-built constant-time comparison function on the final values.

How do I handle the case where the received token has a different length than expected?

For fixed-length tokens like HMAC-SHA256 digests (always 64 hex characters), you can return false immediately on a length mismatch without leaking secret information, because the length itself is not secret. For variable-length secrets, pad both inputs to the same fixed length before passing them to your constant-time comparison function.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.