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"| Client

Advantages: → 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"| Server

Advantages: → 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 attackshmac.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.
#

← Previous: CSRF   Next: Authorization

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact