Context
Most of the time, when you are designing and writing backend services, you don't think about what security details you might be leaking. You are focused on performance and correctness of the logic. That's a good thing, but sometimes even simple logic can hide sneaky bugs or information leaks.
We will write an HTTP handler that performs a classic login request with email and password in Go. You can replicate this in any language, Go is simple enough to be understood by everyone.
A naive login handler
Here is where we can start: a Go HTTP handler that accepts a login POST.
func main() {
http.HandleFunc("POST /login", loginHandler)
log.Println("Server running on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
var payload LoginRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token, refreshToken, err := authenticate(payload.Email, payload.Password)
if err != nil {
http.Error(w, "invalid email or password", http.StatusUnauthorized)
return
}
_ = refreshToken
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(LoginResponse{
Token: token,
})
}
The core part is inside the authenticate function.
This is obviously a simplified version, normally you would look up a database or call another service, but it is enough to show the problem.
// findUserByEmail simulates a DB lookup.
func findUserByEmail(email string) (User, error) {
user, ok := usersDB[email]
if !ok {
return User{}, errInvalidCredentials
}
return user, nil
}
func authenticate(email, password string) (string, string, error) {
user, err := findUserByEmail(email)
if err != nil {
return "", "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password)); err != nil {
return "", "", errInvalidCredentials
}
return "dummy-jwt-token", "dummy-refresh-token", nil
}
The variable usersDB is an in-memory map that simulates a database, for the sake of this example.
Spot the difference
What if I send a GET request?
> curl localhost:8080/login
Method Not Allowed
Good. Now let's try to authenticate with a non-existing user:
> time curl localhost:8080/login -d '{"email":"[email protected]","password":"pwd"}'
invalid email or password
curl localhost:8080/login -d '{"email":"[email protected]","password":"pwd"}' 0.00s user 0.00s system 60% cpu 0.012 total
I added a hardcoded email in the usersDB map, just to have an existing user to test against:
> time curl localhost:8080/login -d '{"email":"[email protected]","password":"pwd"}'
invalid email or password
curl localhost:8080/login -d '{"email":"[email protected]","password":"pwd"}' 0.00s user 0.00s system 11% cpu 0.068 total
The error message is the same, but the request with an existing user takes noticeably longer.
Let's run more than just one request to see if this pattern is consistent.
I'm using hey here, a tool for quick HTTP benchmarks.
# wrong email, wrong password
echo 'POST http://localhost:8080/login' | hey -n 200 -c 1 -m POST \
-d '{"email":"[email protected]","password":"pwd"}' \
http://localhost:8080/loginSummary:
Total: 0.0205 secs
Slowest: 0.0041 secs
Fastest: 0.0000 secs
Average: 0.0001 secs
Requests/sec: 9769.0833
Total data: 5200 bytes
Size/request: 26 bytes# correct email, wrong password
echo 'POST http://localhost:8080/login' | hey -n 200 -c 1 -m POST \
-d '{"email":"[email protected]","password":"wrong"}' \
http://localhost:8080/loginSummary:
Total: 8.8433 secs
Slowest: 0.0595 secs
Fastest: 0.0438 secs
Average: 0.0442 secs
Requests/sec: 22.6159
Total data: 5200 bytes
Size/request: 26 bytes
The difference is massive.
Wrong email: ~0.1ms average. Correct email: ~44ms average.
The error message is always the same "invalid email or password", but the response time tells a completely different story.
What an attacker gains from this
This is a classic timing side-channel that enables user enumeration. An attacker doesn't need to see different error messages, they just need a stopwatch.
By sending login requests with different email addresses and measuring response times, an attacker can build a list of valid accounts on your system. Once they know which emails are registered, they can focus their efforts: targeted password brute-forcing, credential stuffing from leaked databases, or phishing campaigns aimed at confirmed users.
The scary part is that this works even when you've done the "right thing" by returning a generic error message. The information leak is in the timing, not the text.
The fix
If you look at the flow, the problem is here:
user, err := findUserByEmail(email)
if err != nil {
return "", "", err
}
When the user is not found, we return immediately.
We are not leaking information through the error message, err is always errInvalidCredentials, but it's obvious that when the email exists, something different happens: the password comparison via bcrypt.CompareHashAndPassword, which is intentionally slow.
The fix is to make both code paths take roughly the same time:
func findUserByEmail(email string) (User, error) {
user, ok := usersDB[email]
if !ok {
bcrypt.CompareHashAndPassword([]byte("$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyz"), []byte("dummy"))
return User{}, errInvalidCredentials
}
return user, nil
}
When the user is not found, we perform a dummy bcrypt comparison so that the response time matches the case where only the password is wrong. No timing difference, no information leak.
Let's re-run the benchmarks. I'll just show the summary, but you can test this with your own implementation.
With correct email and wrong password:
Summary:
Total: 8.8287 secs
Slowest: 0.0539 secs
Fastest: 0.0437 secs
Average: 0.0441 secs
Requests/sec: 22.6533
Total data: 5200 bytes
Size/request: 26 bytes
With wrong email and wrong password:
Summary:
Total: 8.8625 secs
Slowest: 0.0540 secs
Fastest: 0.0437 secs
Average: 0.0443 secs
Requests/sec: 22.5669
Total data: 5200 bytes
Size/request: 26 bytes
Now the results are nearly identical. An attacker can no longer distinguish between the two cases.
Beyond this example
Timing side-channels are not limited to login endpoints. The same class of vulnerability can appear in:
- Token or API key validation: comparing secrets byte-by-byte and returning early on the first mismatch leaks how many characters are correct. Use constant-time comparison functions like
ConstantTimeCompare[https://pkg.go.dev/crypto/subtle#ConstantTimeCompare] in Go. - Password reset flows: if "user not found" returns instantly but "email sent" takes time to hit an SMTP server, the timing difference is the same problem.
- Any lookup that branches on user input: even a 404 vs 200 with different processing times can leak whether a resource exists.
Also worth noting: timing normalization is just one layer of defense. In a production system you should also consider rate limiting, account lockout policies, and CAPTCHA challenges to make enumeration impractical even if a small timing difference remains.
Conclusion
The takeaway is that the fastest response is not always the best response. Sometimes, standardizing response time is more important than returning as quickly as possible. Security is not just about what your endpoint says, it's about how long it takes to say it.