Articles / Security

  • security
  • authentication
  • username-enumeration
  • go
  • appsec
  • timing-attack

Why Your Login Form Tells Attackers Which Usernames Exist

You wrote a generic error message, "Invalid username or password", and felt rather pleased with yourself. Then an attacker times two requests and works out which accounts exist anyway. Username enumeration is rarely one bug; it is four or five small leaks, and fixing the wording only closes the most obvious one.

This is a tour of where the leaks are and how to close each of them, with a Go example for the nasty one.

Why a list of valid usernames is worth having

A confirmed username halves the attacker's problem. They no longer guess pairs; they take one known account and try passwords against it, or aim phishing and credential stuffing at real people only.

Enumeration also leaks information about the people. "Is this email address registered with this clinic / dating site / forum?" is a privacy question, not just a security one. The existence of an account can itself be personal data.

The five places an account gives itself away

  • The error text: "No such user" versus "Wrong password".
  • The status code or redirect: a 401 for one case, a 302 back to the form for the other.
  • The response time: a password hash is only computed if the user exists.
  • The registration form: "That email is already taken".
  • The password reset form: "We have sent you an email" versus "No account found".

Check all five. Teams routinely fix the first and ship the other four.

Same words, same status, same body

The login handler should return one response for every failure. Not "nearly the same": identical status, identical body, identical headers where you can manage it.

A sneaky variant is a hidden form field or a different cookie set only on one path. Diff the raw responses with curl -i for a real account and a made-up one. If the diff is not empty, you have a leak.

The timing leak: the one people miss

A deliberately slow password hash is the right choice, and it is exactly what makes this leak so visible. If the code looks up the user, finds nothing and returns early, that response comes back in a millisecond. A real account costs you the hash, perhaps 50ms or more. That gap is trivially measurable over a network, especially if the attacker repeats the request and takes the median.

The fix is to do the same work either way: when the user is missing, hash against a dummy record.

package auth

import (
	"crypto/pbkdf2"
	"crypto/sha256"
	"crypto/subtle"
)

// Simplified: PBKDF2 keeps this dependency-free. Use your real
// password hash (argon2id, bcrypt) with the same cost parameters.
const iterations = 600000

type user struct {
	salt []byte
	hash []byte
}

// A record that can never match, but costs the same to check.
var dummy = user{
	salt: []byte("0123456789abcdef"),
	hash: make([]byte, 32),
}

func Check(users map[string]user, name, password string) bool {
	u, found := users[name]
	if !found {
		u = dummy
	}
	got, err := pbkdf2.Key(sha256.New, password, u.salt, iterations, 32)
	if err != nil {
		return false
	}
	match := subtle.ConstantTimeCompare(got, u.hash) == 1
	return found && match
}

Note that found && match is evaluated after the hash and the comparison, so neither path short-circuits early. The dummy hash must use the same algorithm and cost as real ones, otherwise you have just moved the gap.

Quick detour: why the dummy hash needs the same cost

Hang on, why not just time.Sleep for a fixed period? Because a fixed sleep is a guess about hash time, and that time varies with load, hardware and tuning. When you raise your hash cost next year, the sleep goes stale and the leak quietly returns.

Running the real function on a dummy record tracks the real cost automatically. It is also less code to keep in step.

Registration and password reset

These two forms are harder, because the honest answer to "is this email taken?" is useful to a legitimate user. The usual compromise is to respond the same way regardless, and put the real outcome in an email.

  1. Registration: always say "Check your email to continue".
  2. If the address is new, send a confirmation link.
  3. If it already exists, send a "you already have an account" message instead.
  4. Password reset: always say "If that account exists, we have sent a link".

Timing applies here too. Sending an email synchronously only for real accounts is a slow path that an attacker can spot, so queue the send and return immediately in both cases.

Lockouts and rate limits can leak as well

If "too many attempts" only ever appears for real accounts, you have built an oracle. Lock out by username whether or not it exists, or rate limit by source address and by attempted name together.

Be careful with per-account lockout as a policy: it lets anyone lock a known user out on purpose. Rate limiting by address, plus slowing repeated attempts rather than hard-locking, avoids handing that to an attacker.

Checking your own form

Script two requests, one real and one invented, and compare status, body, headers and timings over a few hundred runs each. Look at the medians, not single samples.

If you cannot fully hide enumeration, say on registration where usability wins, then make the other paths solid and add rate limits. Perfect secrecy about account existence is not always on offer; closing the cheap leaks is.