skip to content

In PHP, why compare a CSRF token with hash_equals() instead of ===, and which argument goes first?

level: middleimportance: must knowfreq 45%

answer

  1. === short-circuits, leaking timing
  2. hash_equals compares in constant time
  3. known secret first, user input second
  4. returns bool; TypeError on non-string
  5. still leaks length on a mismatch

basics

~20 s

=== returns as soon as it finds a differing byte, so its run time leaks how much of the secret matched — a timing side channel. hash_equals($known, $user) compares in constant time for equal-length strings. Pass the stored token first and the user-supplied value second.

solid answer

~40 s

`===` on strings **short-circuits** at the first differing byte, so its run time reveals how many leading bytes matched, and an attacker timing many guesses can recover a token byte by byte. **`hash_equals(string $known_string, string $user_string): bool`** avoids that: for equal-length strings it examines every byte regardless of where they differ. Argument order matters — the **known secret goes first, the user-supplied value second** (`hash_equals($_SESSION['csrf'], $_POST['csrf'])`). Both arguments must be strings or PHP throws a **`TypeError`**, so guard a possibly-missing `$_POST` field with `?? ''`. One limitation: when the strings differ in **length**, `hash_equals` returns `false` immediately and may leak the known string's length — it protects the *contents*, not the length, which is fine for a high-entropy token. The theory of *why* constant-time matters is owned by the hashing concept home.

go deeper

for a junior

Know that token comparison uses hash_equals, not == or ===.

for a middle

Explain the timing short-circuit in ===, that hash_equals is constant-time for equal lengths, and that the known secret is the first argument.

for a senior

Handle the TypeError on a missing field with ?? '', and know hash_equals still leaks length on a mismatch.

for a principal

Judge where constant-time comparison is and isn't required across a system, and standardise the token-check helper.

## The problem with `===` PHP compares strings with `===` by walking the bytes and returning `false` at the **first** mismatch. That makes the comparison faster when the strings differ early and slower when they share a long common prefix. An attacker who can submit token guesses and measure response time can exploit that: hold everything fixed, vary one byte, and the guess that takes measurably longer is the one that matched one more byte. Repeated across positions, this recovers a secret token without ever guessing it whole — a **timing side channel**. ## What `hash_equals` does `hash_equals(string $known_string, string $user_string): bool` is built to compare secrets safely: - For **equal-length** strings it inspects **every byte** and combines the differences, so its run time does not depend on *where* the strings differ. - It returns a plain `bool` — `true` if equal, `false` otherwise. - It is the right tool for any secret-vs-supplied comparison: CSRF tokens, HMAC/signature tags, API keys, password-reset tokens. ```php $ok = hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? ''); ``` ## Argument order and the type contract Two mechanical facts interviewers check: 1. **Order.** The manual cautions: *provide the user-supplied string as the second parameter.* So it is `hash_equals($known /* secret */, $user /* from the request */)`. Getting it backwards can leak the length of the value you meant to keep secret. 2. **Types.** Both parameters must be `string`. Passing an array or `null` (for example a missing `$_POST['csrf']`) throws a **`TypeError`** — "argument 1/2 must be of type string" — rather than returning `false`. Coalesce the request side to `''` so a missing field becomes a clean mismatch, not a crash. | Call | Result | |---|---| | `hash_equals($secret, $userInput)` | correct — constant-time content compare | | `hash_equals($userInput, $secret)` | works but leaks the wrong string's length; wrong order | | `hash_equals($secret, $_POST['csrf'])` when field absent | `TypeError` (null given) | | `hash_equals($secret, $_POST['csrf'] ?? '')` | safe — missing field ⇒ `false` | ## The one thing it does not hide `hash_equals` protects the **contents** against timing analysis, not the **length**. When the two strings differ in length it returns `false` **immediately** and may leak the length of `known_string`. For a fixed-format, high-entropy CSRF token this is a non-issue — the length is not secret and knowing it does not help guess the value. It matters only for variable-length secrets where the length itself is sensitive, which CSRF tokens are not. ## Common mistakes - Using `==`, which adds type-juggling bypasses on top of the timing problem (a bare-digit or `0e…` token could compare equal — that loose-comparison trap is owned by the sinks leaf). - Using `===`, which is type-safe but still short-circuits and leaks timing. - Reversing the arguments. - Comparing without `?? ''`, turning a missing field into a `TypeError` and possibly a 500 instead of a clean 400.

  • Does `hash_equals` hide everything about the two strings?
    No. It hides the *contents* from timing analysis for equal-length inputs, but when the lengths differ it returns false immediately and may leak the length of the known string. That is fine for CSRF tokens, whose length is fixed and not secret; it matters only when the length itself is sensitive.
  • What happens if `$_POST['csrf']` is missing and you call `hash_equals($_SESSION['csrf'], $_POST['csrf'])`?
    A missing key makes `$_POST['csrf']` null, and `hash_equals` throws a `TypeError` because both arguments must be strings. Guard it with `$_POST['csrf'] ?? ''` so an absent field becomes an empty string and the call returns `false` — a clean rejection rather than a crash.
  • Why is `==` even worse than `===` for a token compare?
    `===` is type-safe but still short-circuits, leaking timing. `==` additionally juggles types before comparing, so certain crafted tokens (numeric or `0e…` strings) can compare equal without matching byte for byte — a correctness bypass on top of the timing leak. Neither is acceptable; use `hash_equals`.

saying these in an interview costs you the question

  • === is safe for comparing secret tokens
  • hash_equals hides the length of the strings too
  • the user-supplied value should be the first argument
  • hash_equals returns false when given a non-string
  • == and === compare strings in constant time
  • argument order does not matter for hash_equals