Stage 7 · Master
Phase 4 — Authentication Service
Security Best Practices
Tie together brute-force lockout, the double-submit CSRF token promised in the refresh-tokens lesson, and a breached-password check — the three defenses that sit outside any single component built so far but are each load-bearing.
Turning failed_attempts and locked_until Into an Actual Defense
The credentials table has carried failed_attempts and locked_until columns since the password-hashing lesson, unused until now. Every failed login increments the counter; a threshold locks the account for a cooldown window; a successful login resets the counter to zero. The design goal is slowing an automated guesser to uselessness without permanently locking out a user who mistypes their password a few times.
package credential
import "time"
const (
maxFailedAttempts = 5
lockoutDuration = 15 * time.Minute
)
// RecordResult is called after every login attempt and returns the updated
// failed_attempts count plus a lockout time if the threshold was just
// crossed, ready to be persisted by the caller in the same transaction that
// read the credentials row.
func RecordResult(currentFailures int, success bool) (newFailures int, lockUntil *time.Time) {
if success {
return 0, nil
}
newFailures = currentFailures + 1
if newFailures >= maxFailedAttempts {
t := time.Now().Add(lockoutDuration)
return newFailures, &t
}
return newFailures, nil
}
// IsLocked reports whether lockedUntil (read from the credentials row)
// still represents an active lockout.
func IsLocked(lockedUntil *time.Time) bool {
return lockedUntil != nil && time.Now().Before(*lockedUntil)
}The login handler checks IsLocked before ever calling credential.Verify — an account under an active lockout gets a generic 401 without spending bcrypt's deliberately expensive CPU time, which prevents repeated requests against an already-locked account from becoming a resource-exhaustion path.
Closing the SameSite Gap Promised Earlier
The refresh-tokens lesson flagged that SameSite=Strict is not a complete CSRF defense on its own. The double-submit pattern adds a second, non-cookie value: on login, the server also returns a CSRF token in the JSON response body (readable by the page's own JavaScript, unlike the httpOnly refresh cookie), and every state-changing request must echo it back in an X-CSRF-Token header. A cross-site attacker's forged request can make the browser attach the cookie automatically, but has no way to read the JSON-delivered token to also set the header.
package httpmw
import "net/http"
// RequireCSRFHeader must run after RequireAuth and only on state-changing
// methods — GET requests carry no double-submit requirement since they
// should never mutate state in the first place.
func RequireCSRFHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
headerToken := r.Header.Get("X-CSRF-Token")
cookieToken, err := r.Cookie("hoa_csrf")
if err != nil || headerToken == "" || headerToken != cookieToken.Value {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"csrf_mismatch","message":"missing or mismatched CSRF token"}`))
return
}
next.ServeHTTP(w, r)
})
}hoa_csrf is deliberately not HttpOnly — the page's JavaScript must be able to read it to place a matching value in the header. It is still Secure and SameSite=Strict so it cannot itself be read or forged cross-site; the security property comes from requiring the header and cookie to match, not from either value being secret on its own.
Rejecting Passwords Already Known to Attackers
Password strength rules (minimum length, character classes) are a weak signal — 'Password1!' satisfies most of them and is one of the first guesses in any credential-stuffing wordlist. A k-anonymity breach check against the Have I Been Pwned Pwned Passwords API is a stronger one: the client hashes the candidate password with SHA-1, sends only the first five hex characters of the hash, and the API returns every suffix sharing that prefix along with its breach count — the full password (and even the full hash) never leaves auth-service.
package credential
import (
"crypto/sha1"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// IsBreached queries the k-anonymity Pwned Passwords range API. Only a
// 5-character hash prefix is ever transmitted, so the full password's hash
// — let alone the password itself — never leaves this process.
func IsBreached(password string) (bool, error) {
sum := fmt.Sprintf("%X", sha1.Sum([]byte(password)))
prefix, suffix := sum[:5], sum[5:]
resp, err := http.Get("https://api.pwnedpasswords.com/range/" + prefix)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, err
}
for _, line := range strings.Split(string(body), "\r\n") {
parts := strings.Split(line, ":")
if len(parts) == 2 && parts[0] == suffix {
count, _ := strconv.Atoi(parts[1])
return count > 0, nil
}
}
return false, nil
}A network failure here should never block registration outright — IsBreached returning an error is treated as 'unknown, allow the password through' rather than a hard failure, since this check is a defense-in-depth improvement over minimum length rules, not the platform's only line of defense.
Two Defenses Already Built, Restated as Policy
- Library-owned verification (password-hashing lesson): every credential comparison uses bcrypt.CompareHashAndPassword, never an application-level == comparison against hash material.
- Algorithm allowlisting (jwt lesson): jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}) is non-negotiable in every service that verifies tokens, including a future API Gateway — copying this middleware without that call re-opens alg confusion.
- Uniform login failure messages: whether a login fails because the user does not exist, the password is wrong, or the account is locked, the HTTP response body and status must be identical — a distinguishable 'no such user' response is a username enumeration oracle.
Testing the Lockout Threshold and CSRF Rejection
Applied exercise
Decide the lockout reset policy for a shared front-desk device
HOA office staff use one shared kiosk device to help residents look up their accounts, and multiple residents' failed login attempts on that device are triggering lockouts on accounts that are not actually under attack.
- Explain why per-account lockout (as built in this lesson) still fires correctly in this scenario, and whether that is actually the wrong behavior or a reasonable one.
- Propose whether an additional per-IP or per-device rate limit is warranted here, and how it should interact with the per-account lockout without weakening the defense meant to stop credential stuffing.
- State how staff should be able to self-service unlock a legitimately-locked-out resident's account without giving staff a blanket 'disable lockout' switch.
Deliverable
A short written policy addressing shared-device lockout without weakening the credential-stuffing defense.
Completion checks
- The answer does not propose disabling or weakening per-account lockout globally to solve a shared-device UX problem.
- A concrete staff unlock mechanism is described, scoped to individual accounts.
Why must the login handler check IsLocked before calling credential.Verify, rather than after?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.