Why compare an API shared secret with crypto/subtle.ConstantTimeCompare rather than Go's == operator?
answer
- how long it takes is data
- one of them returns early
- extend the guess one byte at a time
- the result is an int, not a bool
- different lengths short-circuit, so hash first
basics
~20 sComparing strings with == stops at the first differing byte, so how long it takes depends on how much of the secret the caller guessed. subtle.ConstantTimeCompare examines every byte and returns 1 for equal or 0 otherwise, in time that does not depend on the contents.
solid answer
~50 s`==` on strings or `bytes.Equal` on slices is allowed to return as soon as it finds a mismatch, so the time it takes is a function of the secret's contents — an attacker who can measure it can extend a guessed prefix one byte at a time instead of brute-forcing the whole secret. `subtle.ConstantTimeCompare(x, y []byte) int` reads both slices completely and returns `1` when the contents are equal and `0` otherwise, in time that depends only on the length. Two details bite people: it returns an **int**, not a bool, so the call site reads `== 1`; and it returns 0 immediately when the lengths differ, so it hides contents but not length. The clean pattern is to hash both sides with `sha256.Sum256` and compare the two fixed-size digests, which equalises the length as well. For MAC tags, `hmac.Equal` is the same guarantee with a bool result.
code
go · 7 linesvar adminSecret = []byte(os.Getenv("ADMIN_SECRET"))
func secretOK(presented string) bool {
a := sha256.Sum256([]byte(presented))
b := sha256.Sum256(adminSecret)
return subtle.ConstantTimeCompare(a[:], b[:]) == 1
}go deeper
Remember the rule and the call: never compare secret material with ==, use subtle.ConstantTimeCompare and check its result against 1. Knowing that much is enough at this level.
Explain the mechanism: early return on the first mismatch makes running time a function of the matching prefix, which converts guessing the secret from exponential to linear work.
Show that you know the fix is not complete on its own — the length short-circuit, the surrounding lookup and error paths, and logging can all leak after the compare itself is safe.
Set the standard the codebase is held to: no data-dependent branch on secret material anywhere in the auth path, enforced by review and by a helper everyone calls, rather than argued case by case.
## The problem being solved An internal admin API is protected by a shared secret the caller presents in a header. The server has the expected secret in memory and has to answer one question: is what arrived the same as what we hold? The obvious code is ```go if presented == adminSecret { // do not do this ``` The correctness of that line is not in doubt. Its *timing* is the problem. ## Why == leaks Go's string comparison first compares lengths, then compares the bytes, and it is free to return the moment two bytes differ. (The runtime compares several bytes at a time, which blunts but does not remove the effect.) So the running time of the check is a function of how many leading bytes the caller got right. A caller who can measure the server's response time can therefore play a game: try every possible first byte, keep the one that was measurably slower, then move to the second byte. That turns an exponential search into a linear one — 32 bytes of secret costs a few thousand probes instead of astronomically many. The same applies to `bytes.Equal`, to `strings.EqualFold`, to a hand-written loop with an early `break`, and to a `map` lookup keyed by the secret. ## What ConstantTimeCompare guarantees ```go func subtle.ConstantTimeCompare(x, y []byte) int ``` It returns `1` if the two slices have equal contents and `0` otherwise, and the time it takes is a function of the **length** of the slices and independent of their **contents**. It does not branch on the data; it accumulates a difference across all bytes and collapses it at the end. Because it never returns early, the byte-at-a-time extension attack has nothing to measure. Two call-site details: - **It returns an int.** `if subtle.ConstantTimeCompare(a, b) == 1 { ... }`. Reviewers should look for a comparison against 1 (or against 0); a wrapper that converts to bool is fine, but the conversion has to happen. - **Unequal lengths return 0 immediately.** That is documented behaviour, and it means the function hides contents but not length. If secret length is itself sensitive, or if you would rather not think about it, do not feed it raw secrets of caller-controlled length. ## The pattern that removes the length question ```go a := sha256.Sum256([]byte(presented)) b := sha256.Sum256(expected) ok := subtle.ConstantTimeCompare(a[:], b[:]) == 1 ``` Both digests are always 32 bytes, so the length branch never fires and the comparison is over fixed-size data. Hashing is cheap relative to an HTTP request, and it also means the raw secret is not the thing being scanned byte by byte. ## Neighbours in crypto/subtle The package is a small toolbox of branch-free primitives: `ConstantTimeByteEq`, `ConstantTimeEq`, `ConstantTimeSelect`, `ConstantTimeCopy`, `ConstantTimeLessOrEq`. For comparing message authentication codes specifically, `crypto/hmac` exposes `hmac.Equal(mac1, mac2 []byte) bool` — the same constant-time property with the bool result you wanted in the first place. Using `bytes.Equal` on a MAC tag is a genuine finding, not a style nit. ## Mistakes that undo the fix - Adding your own `if len(a) != len(b) { return false }` before the constant-time call. You have just reintroduced a branch, and worse, you have made it the first thing that happens. - Comparing the username with `==` and only the password in constant time. The username comparison then leaks which usernames exist. - Looking the expected secret up in a map keyed by the presented one. The lookup's cost and its miss path are data-dependent. - Logging the presented secret, or returning a different error message for a wrong-length secret than for a wrong-content one. The timing side channel is not the only side channel. ## How to frame it in an interview Say what leaks (the position of the first difference), why it matters (byte-at-a-time extraction rather than brute force), what the standard library gives you (`subtle.ConstantTimeCompare`, int result, length short-circuit), and the digest trick that makes the length question go away. Then note that the fix is one line, which is why arguing about exploitability is usually the wrong hill.
- subtle.ConstantTimeCompare returns 0 immediately when the lengths differ. Does that matter?It means the call hides the contents but not the length of the secret. For a fixed-length secret that is usually acceptable; the tidy answer is to hash both sides with `sha256.Sum256` and compare the 32-byte digests, so the length branch can never fire regardless of what the caller sends.
- Someone adds an explicit length check before the constant-time compare. What do you say in review?Reject it. The early `if len(a) != len(b)` is exactly the data-dependent branch the constant-time call was chosen to avoid, and putting it first makes it the cheapest thing to measure. Either compare fixed-size digests or let the library handle length itself.
- Which standard-library call would you use to compare two HMAC tags?`hmac.Equal(mac1, mac2 []byte) bool` from `crypto/hmac`. It is constant-time like `subtle.ConstantTimeCompare` but returns a bool, so it reads naturally at the call site. Using `bytes.Equal` on a tag is a real finding — it can return early on the first differing byte.
saying these in an interview costs you the question
- Claims == on Go strings is already constant time
- Thinks ConstantTimeCompare returns a bool
- Adds an early length check in front of the constant-time compare
- Uses bytes.Equal to compare a MAC tag
- Says a timing leak on a compare is theoretical and needs no fix
- Compares the username with == and only the password in constant time