Authentication #
Authentication is the process of verifying identity — making sure the entity claiming to be “Ali” really is Ali, not someone else pretending to be him. It’s the first gate of every security system: if authentication is weak, all other protection layers can be bypassed.
What makes authentication interesting and dangerous at the same time: it intersects directly with user experience. Systems that are too strict frustrate users — too many steps, too frequent re-login prompts, unreasonable password requirements. Systems that are too loose open holes for attackers to exploit. Finding the right balance between security and usability is one of the biggest challenges in system design.
This article discusses authentication from an implementation perspective: how to build it correctly from scratch, the pitfalls that often cause incidents, and battle-tested best practices.
Authentication vs Authorization: A Difference Often Confused #
Before discussing implementation, it’s important to understand the difference between two concepts frequently used interchangeably even though they’re fundamentally different.
Authentication (AuthN) — "Who are you?"
The process of verifying user identity.
Input: credentials (passwords, tokens, biometrics)
Output: a verified identity (or failure)
Example: logging in with a username and password
Authorization (AuthZ) — "What are you allowed to do?"
The process of determining access rights based on identity.
Input: an already-verified identity
Output: the list of allowed operations
Example: regular users can't access admin pages
The correct order:
Request comes in → Authentication → Authorization → Process the request
If authentication fails → reject, no need to check authorization
If authorization fails → reject with 403 Forbidden
If both pass → process the request
A frequent mistake: performing authorization checks without proper authentication checks, or assuming that being logged in means having all access.
Passwords: A Foundation Often Implemented Wrong #
Passwords are still the most common authentication mechanism — and still frequently implemented incorrectly.
Correct Password Hashing #
// ANTI-PATTERN 1: storing plaintext passwords
user.Password = "userpassword123" // NEVER DO THIS
// ANTI-PATTERN 2: encryption (not hashing)
user.Password = encrypt(password, secretKey)
// Encryption can be decrypted. If secretKey leaks, all passwords leak.
// ANTI-PATTERN 3: hashes without salts — vulnerable to rainbow table attacks
user.Password = sha256.Sum256([]byte(password))
// Two users with the same password produce the same hash
// → attackers can precompute rainbow tables and match instantly
// ANTI-PATTERN 4: fast hashes — vulnerable to brute force
// SHA256 can compute billions of hashes per second on modern GPUs
// A 1-million-password database can be cracked in hours if the hash leaks
// CORRECT: use algorithms designed for password hashing.
// golang.org/x/crypto/argon2 — Argon2id (OWASP 2023 recommendation).
func hashPassword(password string, salt []byte) string {
// time = 3 iterations — higher = slower = safer
// memory = 65536 KiB (64MB) — prevents GPU/ASIC attacks
// threads = 2, keyLen = 32 bytes output; salt is random per password
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32)
return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=2$%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash))
}
func verifyPassword(stored, provided string, salt []byte) bool {
// Recompute the hash and compare in constant time
expected := argon2.IDKey([]byte(provided), salt, 3, 64*1024, 2, 32)
return subtle.ConstantTimeCompare([]byte(stored), expected) == 1
}
func needsRehash(stored string) bool {
// Check whether the stored hash still uses the current parameters
return !strings.HasPrefix(stored, "$argon2id$v=19$m=65536,t=3,p=2$")
}
Why Argon2id:
Memory-hard: requires large amounts of RAM
→ Modern GPUs have thousands of cores but limited RAM per core
→ Memory requirements make GPU attacks far more expensive
Time-cost: each hash takes ~100-300ms (configurable)
→ Servers can still handle hundreds of logins per second
→ Attackers brute forcing: can only try hundreds/thousands
of combinations per second (vs billions with MD5)
Hashing speed comparison (RTX 4090 GPU):
MD5: 100+ billion hashes/sec → 8-char passwords gone in seconds
SHA256: 10+ billion hashes/sec
bcrypt: ~10 million hashes/sec
Argon2: ~100 thousand hashes/sec → billions of times slower than MD5
Password Policies That Make Sense #
Overly strict password policies don’t make systems more secure — users will write passwords on sticky notes or use easily guessable patterns.
// Password policy based on NIST SP 800-63B (newer and better)
const (
minLength = 12 // minimum length, not maximum
maxLength = 128 // a maximum exists to prevent DoS via hash computation
pwnedAPIURL = "https://api.pwnedpasswords.com/range/"
)
func validatePassword(password string) (bool, []string) {
var errors []string
// 1. Minimum length
if len(password) < minLength {
errors = append(errors, fmt.Sprintf("Password must be at least %d characters", minLength))
}
// 2. Maximum length
if len(password) > maxLength {
errors = append(errors, fmt.Sprintf("Password must be at most %d characters", maxLength))
}
// 3. Check against known breached password databases (HaveIBeenPwned API)
if isPasswordPwned(password) {
errors = append(errors,
"This password appears in a known breached password list. "+
"Please choose a different password.")
}
// 4. Context checks (don't use the app name, username, etc.)
// This is more useful than a "must contain numbers and uppercase" requirement
return len(errors) == 0, errors
}
func isPasswordPwned(password string) bool {
// Check HaveIBeenPwned using k-Anonymity — the password is never sent
sum := sha1.Sum([]byte(password))
hexStr := strings.ToUpper(hex.EncodeToString(sum[:]))
prefix, suffix := hexStr[:5], hexStr[5:]
resp, err := http.Get(pwnedAPIURL + prefix)
if err != nil {
return false // If the API fails, don't block users
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
body, _ := io.ReadAll(resp.Body)
for _, line := range strings.Split(string(body), "\n") {
parts := strings.Split(line, ":")
if len(parts) == 2 && parts[0] == suffix {
count, _ := strconv.Atoi(parts[1])
return count > 0
}
}
return false
}
What is NOT needed (per NIST):
✗ "Must contain uppercase, numbers, and symbols" requirements
→ Makes users use guessable patterns: Password1!
✗ Mandatory periodic password rotation (e.g. every 90 days)
→ Users choose weak, easy-to-remember passwords when changed often
→ Change passwords ONLY when there are compromise indications
✗ Hints and security questions
→ Security questions are often guessable from public info
What IS needed:
✓ Adequate length (12+ characters)
✓ Checks against breached password databases
✓ Must not equal the username or email
✓ Strength indicators to help users choose strong passwords
Multi-Factor Authentication (MFA) #
MFA adds a second verification layer after passwords. Even if passwords are compromised, attackers still need a second factor they don’t have.
flowchart TD
A["User enters\nusername + password"] --> B{Password correct?}
B --> |No| C["Reject + log\nfailed attempt"]
B --> |Yes| D{MFA enabled?}
D --> |No| E["Login successful\nBut less secure"]
D --> |Yes| F{MFA type?}
F --> G["TOTP\nGoogle Auth/Authy"]
F --> H[SMS OTP]
F --> I["Push notification\nto app"]
F --> J["Hardware key\nYubiKey/FIDO2"]
G --> K{Code valid?}
H --> K
I --> K
J --> K
K --> |No| L[Reject + log]
K --> |Yes| M[Login successful ✓]TOTP (Time-based One-Time Password) #
// TOTP implementation with github.com/pquerna/otp
func setupTOTPForUser(userID int, username string) map[string]any {
// Generate a secure secret key
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MyApp",
AccountName: username,
})
if err != nil {
return nil
}
// Store the secret in the database (encrypted!)
storeMFASecret(userID, encryptSecret(key.Secret()))
// Generate a provisioning URI for the QR code
provisioningURI := key.URL()
// Generate the QR code (github.com/skip2/go-qrcode) from the URI and
// show it to the user to scan with an authenticator app
// Also show the secret key as text (for manual entry)
return map[string]any{
"secret": key.Secret(), // show ONCE, don't store it in plain
"provisioning_uri": provisioningURI,
// backup codes for recovery
"backup_codes": generateBackupCodes(userID),
}
}
func verifyTOTP(userID int, providedCode string) bool {
secret := decryptSecret(getMFASecret(userID))
// validWindow=1 allows 1 interval forward/backward (30 seconds)
// to accommodate slight time differences between the server and device
return totp.Validate(providedCode, secret)
}
func generateBackupCodes(userID int) []string {
// Generate 10 backup codes; store only their hashes
codes := make([]string, 0, 10)
for i := 0; i < 10; i++ {
codes = append(codes, randomHex(4))
}
// Store hashes of the backup codes (not plaintext)
// Users can only see them once at setup
storeBackupCodesHashed(userID, codes)
return codes // show to the user only once
}
MFA options from most to least secure:
1. FIDO2/WebAuthn (hardware keys or passkeys)
→ Can't be phished because they're bound to the domain
→ Most secure, but requires hardware or supported devices
2. TOTP (Google Authenticator, Authy)
→ Time-based, codes change every 30 seconds
→ Phishable if users are tricked into entering codes on fake sites
→ But still far better than SMS
3. Push notifications (Duo, Okta Verify)
→ Users approve in the app → more user-friendly than TOTP
→ Vulnerable to MFA fatigue attacks (notification spam until approval)
4. SMS OTP
→ Vulnerable to SIM swapping attacks
→ Better than no MFA at all
→ Avoid for high-risk applications (banking, crypto)
Session vs Token-Based Authentication #
There are two main approaches to maintaining authentication state after a successful login.
Session-based (stateful):
flowchart LR
Server["Server (Login)"] -->|"store"| DB["Database/Redis<br>session_id<br>user_id: 1<br>expires: .."]
Client["Client<br>Cookie: session=X"] -->|"read/verify"| DB
Server -->|"set-cookie"| ClientAdvantages: → Sessions can be revoked anytime (deleted from the database) → The server always has current session information → Ideal for traditional web apps
Disadvantages: → Needs shared storage with multiple server instances → Extra lookup latency to the database/Redis
Token-based (stateless) — JWT:
flowchart LR
Server["Server (Login)"] -->|"sign & send"| Client["Client<br>Header.Payload.Signature"]
Client -->|"send token"| ServerAdvantages: → Stateless — no database lookup needed for verification → Easy horizontal scaling (no shared session store needed) → Verifiable by multiple services without talking to the auth server
Disadvantages: → Can’t be revoked before expiry (without additional mechanisms) → Payloads are readable by anyone (only the signature can’t be forged)
JWT — Pitfalls Often Overlooked #
// ANTI-PATTERN 1: the "none" algorithm — disables signature verification
token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{"user_id": 1})
// Anyone can create a token without a signature
// ANTI-PATTERN 2: weak HS256 keys
token, _ = jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{"user_id": 1}).SignedString([]byte("secret"))
// "secret" can be cracked with wordlist attacks in seconds
// ANTI-PATTERN 3: overly long expiries
claims := jwt.MapClaims{
"user_id": 1,
"exp": time.Now().Add(365 * 24 * time.Hour).Unix(), // 1 year!
}
// A compromised token stays valid for 1 year with no way to revoke it
// CORRECT: secure JWT configuration (github.com/golang-jwt/jwt/v5)
// HS256: secret keys of at least 256 bits (32 bytes)
jwtSecret := os.Getenv("JWT_SECRET") // 32+ byte random key
if len(jwtSecret) < 32 {
panic("JWT secret too short")
}
// Or RS256 for multi-service architectures
// RS256 lets other services verify tokens without knowing the private key
func createAccessToken(userID int, additionalClaims map[string]any) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"sub": strconv.Itoa(userID), // subject
"iat": now.Unix(), // issued at
"exp": now.Add(15 * time.Minute).Unix(), // short expiry!
"jti": randomToken(16), // unique token ID
"type": "access", // distinguish access vs refresh tokens
}
for k, v := range additionalClaims {
claims[k] = v
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtSecret))
}
func verifyAccessToken(tokenStr string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// explicit — don't accept "none" or mixed algorithms
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
// require exp, iat, sub, jti, type
if claims["type"] != "access" {
return nil, errors.New("wrong token type")
}
// Optional: check a token blacklist if revocation is needed
if isTokenRevoked(claims["jti"].(string)) {
return nil, errors.New("token has been revoked")
}
return claims, nil
}
Brute Force Protection #
// Redis-backed rate limiter (github.com/redis/go-redis/v9)
type RateLimiter struct {
client *redis.Client
maxAttempts int
windowSeconds time.Duration
lockoutSeconds time.Duration
}
func (r *RateLimiter) checkRateLimit(ctx context.Context, identifier string) (bool, int) {
key := "login_attempts:" + identifier
lockoutKey := "login_lockout:" + identifier
// Check whether currently in lockout
lockoutRemaining, _ := r.client.TTL(ctx, lockoutKey).Result()
if lockoutRemaining > 0 {
return false, int(lockoutRemaining.Seconds())
}
// Check the attempt count within the window
attempts, _ := r.client.Get(ctx, key).Int()
if attempts >= r.maxAttempts {
// Set the lockout
r.client.Set(ctx, lockoutKey, 1, r.lockoutSeconds)
r.client.Del(ctx, key)
return false, int(r.lockoutSeconds.Seconds())
}
return true, 0
}
func (r *RateLimiter) recordAttempt(ctx context.Context, identifier string) {
// Record one attempt
key := "login_attempts:" + identifier
pipe := r.client.Pipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, r.windowSeconds)
pipe.Exec(ctx)
}
func (r *RateLimiter) resetAttempts(ctx context.Context, identifier string) {
// Reset after a successful login
r.client.Del(ctx, "login_attempts:"+identifier)
r.client.Del(ctx, "login_lockout:"+identifier)
}
// Per-IP limiter: 10 attempts per 5 minutes
ipLimiter := &RateLimiter{client: rdb, maxAttempts: 10,
windowSeconds: 5 * time.Minute, lockoutSeconds: 5 * time.Minute}
// Per-account limiter: 5 attempts per 15 minutes (stricter)
accountLimiter := &RateLimiter{client: rdb, maxAttempts: 5,
windowSeconds: 15 * time.Minute, lockoutSeconds: 15 * time.Minute}
// Login handler (net/http)
func loginHandler(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
password := r.FormValue("password")
ip := remoteIP(r)
// Check the per-IP rate limit
allowed, retryAfter := ipLimiter.checkRateLimit(r.Context(), ip)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, `{"error": "Too many attempts. Please try again in a few minutes."}`, http.StatusTooManyRequests)
return
}
// Check the per-account rate limit
allowed, retryAfter = accountLimiter.checkRateLimit(r.Context(), email)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, `{"error": "Account temporarily locked due to too many attempts."}`, http.StatusTooManyRequests)
return
}
// Record the attempt BEFORE verification (prevents timing attacks)
ipLimiter.recordAttempt(r.Context(), ip)
accountLimiter.recordAttempt(r.Context(), email)
user := findUserByEmail(email)
// IMPORTANT: always verify the password (timing-safe)
// If the user doesn't exist, still run verify for consistent timing
if user == nil {
verifyPassword(dummyHash, password) // timing dummy
http.Error(w, `{"error": "Invalid email or password"}`, http.StatusUnauthorized)
return
}
if !verifyPassword(user.PasswordHash, password) {
http.Error(w, `{"error": "Invalid email or password"}`, http.StatusUnauthorized)
return
}
// Login successful — reset the rate limits
ipLimiter.resetAttempts(r.Context(), ip)
accountLimiter.resetAttempts(r.Context(), email)
// Check whether the hash needs upgrading
if needsRehash(user.PasswordHash) {
user.PasswordHash = hashPassword(password, newSalt())
}
sessionToken := createSession(user.ID, r)
setAuthCookie(w, sessionToken)
w.WriteHeader(http.StatusOK)
}
Secure Password Resets #
The password reset flow is one of the most frequently weakly implemented — yet it can become a serious account takeover hole.
// Password reset flow
var resetTokenExpiry = 15 * time.Minute
func requestPasswordReset(email string) {
user := findUserByEmail(email)
// IMPORTANT: the response is ALWAYS the same whether or not the email exists
// Prevents user enumeration (knowing whether an email is registered)
if user != nil {
// Generate a strong token
rawToken := randomToken(32)
// Store the HASH of the token (not the token itself)
// If the database leaks, tokens can't be used directly
tokenHash := sha256Hex(rawToken)
// Invalidate all previous reset tokens
deleteResetTokensForUser(user.ID)
createResetToken(ResetTokenRecord{
UserID: user.ID,
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(resetTokenExpiry),
Used: false,
})
// the rawToken is sent to the email, not the tokenHash
sendResetEmail(user.Email,
"https://app.com/reset-password?token="+rawToken)
}
// The response is always the same — don't distinguish "email exists" vs "doesn't"
_ = jsonResponse(map[string]string{
"message": "If the email is registered, reset instructions will be sent.",
})
}
func resetPassword(rawToken, newPassword string) error {
tokenHash := sha256Hex(rawToken)
record := findResetToken(tokenHash, false)
if record == nil {
return errors.New("invalid or already-used token")
}
if record.ExpiresAt.Before(time.Now()) {
return errors.New("token has expired")
}
// Validate the new password
isValid, errorsList := validatePassword(newPassword)
if !isValid {
return errors.New(strings.Join(errorsList, "; "))
}
// Update the password
user := findUserByID(record.UserID)
user.PasswordHash = hashPassword(newPassword)
// Mark the token as used (single-use)
record.Used = true
record.UsedAt = time.Now()
// IMPORTANT: invalidate all active sessions after the reset
// If the account was compromised and the attacker set the password,
// the attacker's sessions must be invalidated too
invalidateAllSessions(user.ID)
saveResetToken(record)
// Send an email notification that the password was changed
sendPasswordChangedNotification(user.Email)
return nil
}
Authentication Checklist #
PASSWORD:
□ Passwords hashed with Argon2id, bcrypt, or scrypt — not MD5/SHA256
□ Salts auto-generated for every password (Argon2 does this)
□ Minimum length of 12 characters
□ Checks against the HaveIBeenPwned API for known breached passwords
□ No unnecessary complexity requirements (mandatory symbols, etc.)
□ No forced periodic password rotation
MFA:
□ MFA available for all users
□ MFA mandatory for admin and high-privilege accounts
□ TOTP or FIDO2 prioritized over SMS OTP
□ Backup codes available if devices are lost
□ Secure recovery process if users lose MFA access
BRUTE FORCE PROTECTION:
□ Rate limiting per IP and per account
□ Account lockout after N failed attempts
□ Error messages don't distinguish "email not found" vs "wrong password"
□ Constant-time comparisons for credential verification
□ All failed attempts logged with metadata (IP, timestamp, user agent)
SESSION & TOKEN:
□ Session IDs created with CSPRNGs (secrets.token_urlsafe)
□ JWTs use explicit algorithms, short expiries, and unique jtis
□ Sessions invalidated on logout
□ Sessions invalidated when passwords change
□ Idle and absolute timeouts configured
PASSWORD RESET:
□ Reset tokens created with CSPRNGs (32+ bytes)
□ Reset tokens hashed before database storage
□ Tokens valid for only 15-60 minutes
□ Tokens single-use — immediately invalid after use
□ Reset responses don't leak whether emails are registered
□ All sessions invalidated after password resets
□ Email notifications sent after successful password changes
MONITORING:
□ Failed login attempts logged and alerted on anomalies
□ Logins from new/unusual locations trigger notifications
□ Concurrent logins from impossible locations detected
Summary #
- Authentication and authorization are two different things — authn verifies identity, authz verifies access rights. Both must be done separately and sequentially.
- Argon2id is the standard for password hashing — memory-hard, time-configurable, with automatic salts. MD5, SHA256, and encryption aren’t valid choices for passwords.
- Overly strict password policies actually weaken security — focus on minimum length and checks against breached password databases (HaveIBeenPwned), not complex special-character requirements.
- MFA is the most effective protection layer against credential compromise — even if passwords leak, attackers still need a second factor. FIDO2/WebAuthn is the most secure because it can’t be phished.
- Session-based auth allows revocation, JWTs don’t — sessions can be invalidated anytime. Compromised JWTs stay valid until expiry, unless additional revocation mechanisms exist.
- JWTs must use explicit algorithms and short expiries — don’t allow the ’none’ algorithm, use HS256/RS256, set access token expiries to 15 minutes.
- Brute force protection must exist at two levels — per IP and per account. Both are needed because attackers can come from many IPs or target one account from one IP.
- Constant-time comparisons for credential verification prevent timing attacks —
hmac.compare_digest or secrets.compare_digest, not the regular == operator. - Password reset tokens must be hashed before storage — like passwords, store hashes not raw tokens. If the database leaks, tokens can’t be used directly.
- Password resets must invalidate all active sessions — if an account is compromised and the attacker triggered the reset, their sessions must be deleted too.
#
- Authentication and authorization are two different things — authn verifies identity, authz verifies access rights. Both must be done separately and sequentially.
- Argon2id is the standard for password hashing — memory-hard, time-configurable, with automatic salts. MD5, SHA256, and encryption aren’t valid choices for passwords.
- Overly strict password policies actually weaken security — focus on minimum length and checks against breached password databases (HaveIBeenPwned), not complex special-character requirements.
- MFA is the most effective protection layer against credential compromise — even if passwords leak, attackers still need a second factor. FIDO2/WebAuthn is the most secure because it can’t be phished.
- Session-based auth allows revocation, JWTs don’t — sessions can be invalidated anytime. Compromised JWTs stay valid until expiry, unless additional revocation mechanisms exist.
- JWTs must use explicit algorithms and short expiries — don’t allow the ’none’ algorithm, use HS256/RS256, set access token expiries to 15 minutes.
- Brute force protection must exist at two levels — per IP and per account. Both are needed because attackers can come from many IPs or target one account from one IP.
- Constant-time comparisons for credential verification prevent timing attacks —
hmac.compare_digestorsecrets.compare_digest, not the regular==operator. - Password reset tokens must be hashed before storage — like passwords, store hashes not raw tokens. If the database leaks, tokens can’t be used directly.
- Password resets must invalidate all active sessions — if an account is compromised and the attacker triggered the reset, their sessions must be deleted too.