Comparing Secrets Without Leaking Them
How the Iddio Control Server checks secrets: constant-time comparison where it must compare, and hash-then-look-up for session ids and enrollment tokens so there is no secret comparison to time at all.
Every authentication check eventually comes down to comparing something the client sent with something the server holds. Done naively, that comparison is a side channel. This post covers how the Iddio Control Server handles it, and why for most of its secrets the best answer is to not compare the secret at all.
Why == Is a Side Channel
Ordinary equality stops at the first difference. Go’s string == compares in word-sized chunks rather than one byte at a time, but it still returns as soon as a chunk differs. A guess whose first eight bytes are right takes slightly longer to reject than one that is wrong from the start.
That difference is tiny, but an attacker who can submit many guesses and time the responses can, in principle, use it to recover a secret prefix by prefix. Network jitter makes the attack harder, not impossible, and it is not something you want to reason about case by case.
Go’s standard library has the fix: crypto/subtle.ConstantTimeCompare touches every byte of equal-length inputs before answering. It does return early when the lengths differ, so it hides what the secret is, not how long it is. For the fixed-length random values below, that is fine.
Where We Compare: OIDC State and Nonce
Operators sign in to the Control Server’s admin API with OIDC. Two values in that flow are secrets that have to be compared directly, because the server only learns the expected value from the login it started.
The state parameter is the CSRF guard. The server stashes a random state in a short-lived cookie, and the identity provider echoes it back on the callback:
// CSRF guard: the state the IdP echoed MUST equal the one we stashed. A
// constant-time compare avoids leaking a timing oracle on the state value.
echoedState := r.URL.Query().Get("state")
if subtle.ConstantTimeCompare([]byte(echoedState), []byte(stashed.State)) != 1 {
writeError(w, http.StatusBadRequest, "sign-in state did not match ...")
return
}
The nonce binds the ID token to this particular login, and is checked after the token’s signature has been verified:
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(p.Nonce)) != 1 {
return Claims{}, ErrNonceMismatch
}
Both values are random and single-use, so a timing attack against them is far-fetched. We use the constant-time compare anyway, because it costs nothing and means nobody has to argue about whether this particular comparison was safe to leave as ==.
Where We Don’t Compare: Hash, Then Look Up
The Control Server holds two kinds of bearer secret: operator session ids (the admin API’s session cookie) and enrollment tokens (the single-use tokens a daemon redeems for its device certificate). Neither is ever compared.
Both follow the same discipline:
- Generate a high-entropy random value: at least 256 bits for a session id, at least 128 bits for an enrollment token.
- Store only its SHA-256 hash, with an expiry. The plaintext is returned to the caller exactly once and never persisted or logged.
- To authenticate, hash whatever the client presented and look the row up by that hash.
// Resolve: hash the cookie value, look the row up by hash, reject when
// missing or expired, and return the operator the session authenticates.
op, err := s.store.resolveOperator(ctx, hashID(plaintextID), s.now())
There is no if presented == stored anywhere in that path. The database finds a row or it doesn’t. Because the lookup key is a SHA-256 digest, an attacker can’t steer it a byte at a time: changing one character of the guess changes the whole hash, so knowing that a guess “got further” tells them nothing about the real secret.
Storing only the hash also protects against a different failure: a leaked database backup or a stray SELECT * exposes digests, not live session cookies or unredeemed enrollment tokens.
Why SHA-256 and Not bcrypt
Password storage calls for a slow, memory-hard hash such as bcrypt, scrypt or Argon2, because passwords are low-entropy and an attacker holding the hashes can try likely candidates offline.
That reasoning doesn’t apply here. As the session code puts it:
// SHA-256 (not a slow KDF) is correct here because the input is
// high-entropy random: there is nothing to brute-force, so a memory-hard
// hash would add cost for no security gain.
Nobody can enumerate 2²⁵⁶ possible session ids, so a slow hash would add latency to every admin request and buy nothing.
What About the Daemon?
The daemon doesn’t have a shared-secret comparison to protect. Local clients are authenticated by the kernel, using peer credentials on the IPC socket, rather than by a token the daemon would have to compare. Once enrolled, the daemon talks to the Control Server over mutual TLS with its device certificate, and the TLS handshake proves possession of the key without anything secret crossing the wire.
Takeaways
- If you must compare a secret with something the client sent, use
subtle.ConstantTimeCompare. It is one line and removes the question. - If you’re storing a high-entropy bearer secret, store its SHA-256 hash and look it up by hash. That avoids the comparison entirely and limits the damage if the database leaks.
- Save slow password hashes for low-entropy inputs. For random tokens they cost you speed and add no security.
Try It Yourself
Iddio is open source. Deploy a zero-trust command proxy for your AI agents in minutes.