2026-01-05
It is well-known that many standard-library string equality functions are not constant-time and can leak information about shared prefixes through timing variation. I did a quick search and found claims that mention JavaScript, PHP, and C/C#.
It is probably not shocking that string comparison in Go is not
constant time. The Go spec makes no constant-time guarantees for string comparison, and the
standard library explicitly provides constant-time primitives for secrets (e.g. hmac.Equal,
subtle.ConstantTimeCompare).
I recently ran into a case where an untrusted code sandbox had access to a risky local Go HTTP services that was protected by an API key. The untrusted code could communicate with this HTTP service but it required knowing a secret key. The HTTP middleware used a simple string comparison for authentication checks. Something like:
if config.SecretKey != r.GetHeader("X-API-KEY") {
// invalid key
return false
}
// valid key, authenticated
return true
I wasn't sure if this was just non-ideal, or something I could trivially and repeatably exploit for a flashy proof-of-concept, so I dug into it.
The Go compiler turns string equality in Go source ("foo" == "bar") into
a runtime.memequal call which is implemented in ASM. The ARM64 implementation
of memequal does, roughly, quick comparisons for 0-length strings and pointer
equality and then does the bulk of its comparison in 64 then 16 byte chunks of
data. You can see this in a Benchmark on my M1 MacBook.
For smaller strings under 64 bytes:
// BenchmarkEqual measures the performance of comparing two strings of 20 bytes.
// Each subtest increases the length of matching prefix between the two strings.
func BenchmarkEqual(b *testing.B) {
const length = 20
// Create a string of "L"
l := strings.Repeat("L", length)
for i := range length {
b.Run(fmt.Sprintf("%v", i), func(b *testing.B) {
// Create an i-length prefix of "L"
prefix := l[:i]
// Create a suffix of "R" to fill out remaining `length`.
r := prefix + strings.Repeat("R", length-i)
// Start the benchmark loop
for b.Loop() {
_ = l == r
}
})
}
}
Which consistently gets results like:
$ go test -bench=.
goos: darwin
goarch: arm64
pkg: timing-string
cpu: Apple M1
BenchmarkEqual/0-8 517785946 2.099 ns/op
BenchmarkEqual/1-8 570435402 2.082 ns/op
BenchmarkEqual/2-8 589124877 2.040 ns/op
BenchmarkEqual/3-8 576997975 2.100 ns/op
BenchmarkEqual/4-8 566341833 2.056 ns/op
BenchmarkEqual/5-8 575281704 2.093 ns/op
BenchmarkEqual/6-8 577925181 2.057 ns/op
BenchmarkEqual/7-8 575255043 2.087 ns/op
BenchmarkEqual/8-8 558250860 2.141 ns/op
BenchmarkEqual/9-8 551979507 2.163 ns/op
BenchmarkEqual/10-8 553299555 2.150 ns/op
BenchmarkEqual/11-8 550912333 2.167 ns/op
BenchmarkEqual/12-8 551909376 2.159 ns/op
BenchmarkEqual/13-8 552309458 2.170 ns/op
BenchmarkEqual/14-8 557391378 2.157 ns/op
BenchmarkEqual/15-8 549447510 2.171 ns/op
BenchmarkEqual/16-8 447985536 2.697 ns/op # Increase here
BenchmarkEqual/17-8 447242608 2.678 ns/op
BenchmarkEqual/18-8 445631143 2.683 ns/op
BenchmarkEqual/19-8 450126034 2.694 ns/op
Notice a fairly consistent 2.0 - 2.1 nanosecond per-op measurement until we hit the 16-byte difference where we start to see an increase to 2.6 nanosecond per-op measurements.
For larger strings over 64 bytes, you can see similar results at larger "chunks" of matched prefixes:
func BenchmarkEqualLarger(b *testing.B) {
const length = 132
l := strings.Repeat("L", length)
// A sampling of sizes for brevity
for _, i := range []int{8, 12, 16, 24, 32, 64, 96, 128} {
b.Run(fmt.Sprintf("%v", i), func(b *testing.B) {
prefix := l[:i]
r := prefix + strings.Repeat("R", length-i)
for b.Loop() {
_ = l == r
}
})
}
}
In this variation, you can see memequal timing variations after comparing an equal 64-byte prefix:
$ go test -bench=BenchmarkEqualLarger
goos: darwin
goarch: arm64
pkg: timing-string
cpu: Apple M1
BenchmarkEqualLarger/8-8 466492476 2.296 ns/op
BenchmarkEqualLarger/12-8 538396956 2.225 ns/op
BenchmarkEqualLarger/16-8 537487412 2.230 ns/op
BenchmarkEqualLarger/24-8 536149906 2.233 ns/op
BenchmarkEqualLarger/32-8 535175625 2.242 ns/op
BenchmarkEqualLarger/64-8 345648044 3.485 ns/op # Increase here.
BenchmarkEqualLarger/96-8 347081872 3.461 ns/op
BenchmarkEqualLarger/128-8 265203480 4.484 ns/op
PASS
ok timing-string 9.655s
While memequal can still return early, and therefore add a measurable timing
discrepancy between two strings, exploiting this difference requires you to guess
64- or 16-bytes correctly first.
For one-byte-at-a-time comparison timing attacks, you could feasibly gather a O(1000) timing samples of all possible byte values and find statistical outliers. With 16-byte chunks, assuming random secrets, you basically have to guess an AES key before you can start guessing the next chunk. This is computationally infeasible.
Another hurdle is the time differences between chunks, as shown in the benchmarks above are quite small. Sometimes sub-nanosecond small. The literature that I reviewed hasn't explicitly shown results at this level of precision.
The classic Crosby, Wallach, and Riedi paper explicitly mentions measuring 100 ns differences:
We have shown that, even though the Internet induces significant timing jitter, we can reliably distinguish remote timing differences as low as 20µs. A LAN environment has lower timing jitter, allowing us to reliably distinguish remote timing differences as small as 100 ns (possibly even smaller). These precise timing differences can be distinguished with only hundreds or possibly thousands of measurements.
In "Remote Timing Attacks are Practical" Boneh and Brumley demonstrate exploits with 1 ms of timing variation:
We note that in our tests a zero-one gap of approximately 1 millisecond is sufficient to receive a strong indicator, enabling a successful attack. Thus, networks with less than 1 ms of variance are vulnerable.
A more recent paper on "Timeless Timing Attacks" use a second side-channel, the order of responses from parallel requests, to improve the granularity of timing attacks over the Internet. This paper calls out measuring 100 ns differences:
We show how these attacks result in a 100-fold improvement over typical timing attacks performed over the Internet, and can accurately detect timing differences as small as 100 ns, similar to attacks launched on a local system.
I definitely wouldn't say the lack of results at single nanosecond granularity rules out the possibility of exploitation. As the "timeless" timing attack paper shows, there may be secondary side channels that enable more precise measurements.
Do not use == for comparing secrets in Go. Even if it is tricky to exploit, it
still seems prudent to use timing-independent string comparison on secret data:
The Go specification makes no promises around the behavior of memequal so
even if the current implementation has some useful properties, it is not an
explicit guarantee in future versions of Go.
Some secrets, like passwords picked by a user, are not always random so even chunked timing leaks could be exploitable if there is enough structure to reduce entropy within a chunk.
Not all architectures have this same behavior. For example, the WASM
implementation of memequal operates on 8-byte chunks and the ARM32
implementation works on 4-byte chunks.
My benchmarks are not representative of your workload.
It is fairly inexpensive to use a constant-time string comparison function.