Authentication Best Practices
When to Use
Every system that identifies users needs secure authentication. This covers password-based authentication, multi-factor authentication, session management, and modern authentication protocols.
Decision
| If you need to... | Use... | Why |
|---|---|---|
| Hash passwords | Argon2id | 2025 gold standard - resists GPU/ASIC attacks |
| Hash passwords (legacy systems) | bcrypt (work factor 12+) | Industry standard, battle-tested |
| Prevent brute force | Rate limiting + account lockout | Slows automated attacks |
| Enhance security | Multi-factor authentication (MFA) | Passwords alone are insufficient |
| Enterprise authentication | OAuth 2.0 / OpenID Connect | Delegated authentication, single sign-on |
| API authentication | JWT with short expiry + refresh tokens | Stateless, scalable |
Password Hashing
Argon2id (recommended 2025+):
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=2, # Iterations
memory_cost=19456, # 19 MiB (OWASP minimum)
parallelism=1, # Threads
hash_len=32,
salt_len=16
)
def register_user(username, password):
password_hash = ph.hash(password)
db.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)",
[username, password_hash])
def authenticate_user(username, password):
user = db.execute("SELECT * FROM users WHERE username = ?", [username]).fetchone()
if not user:
return False
try:
ph.verify(user['password_hash'], password)
if ph.check_needs_rehash(user['password_hash']):
new_hash = ph.hash(password)
db.execute("UPDATE users SET password_hash = ? WHERE id = ?",
[new_hash, user['id']])
return True
except VerifyMismatchError:
return False
bcrypt (legacy systems):
$password_hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
if (password_verify($password, $user['password_hash'])) {
if (password_needs_rehash($user['password_hash'], PASSWORD_BCRYPT, ['cost' => 12])) {
$new_hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
}
}
NEVER use:
- MD5, SHA-1, SHA-256 for passwords — too fast, no salt, vulnerable to rainbow tables
- Reversible encryption for passwords — passwords should be one-way hashes
- Custom hash algorithms — use vetted libraries
Rate Limiting and Brute Force Prevention
from flask_limiter import Limiter
limiter = Limiter(app=app, key_func=lambda: request.remote_addr)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
if authenticate_user(username, password):
return create_session(username)
else:
log_failed_login(username, request.remote_addr)
failed_count = get_failed_login_count(username)
if failed_count >= 5:
lock_account(username, duration_minutes=30)
return "Account temporarily locked", 429
return "Invalid credentials", 401
Account lockout strategies:
- Temporary lockout: Lock for 30 minutes after 5 failed attempts
- CAPTCHA: Require CAPTCHA after 3 failed attempts
- Progressive delays: 1s, 2s, 4s, 8s delays between attempts
- Alert user: Email notification of failed login attempts
Multi-Factor Authentication (MFA)
MFA types (in order of security):
- Hardware tokens (YubiKey, Titan Security Key) — phishing-resistant
- TOTP apps (Google Authenticator, Authy) — time-based codes
- SMS codes — vulnerable to SIM swapping, but better than nothing
- Email codes — weakest MFA, email accounts often insecure
TOTP implementation:
import pyotp
def enable_mfa(user_id):
secret = pyotp.random_base32()
db.execute("UPDATE users SET mfa_secret = ? WHERE id = ?", [secret, user_id])
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(
name=user['email'], issuer_name='YourApp')
return qrcode.make(totp_uri)
def verify_mfa(user_id, code):
user = db.execute("SELECT * FROM users WHERE id = ?", [user_id]).fetchone()
totp = pyotp.TOTP(user['mfa_secret'])
return totp.verify(code, valid_window=1) # Allow 30s clock drift
Session Management
session_start([
'cookie_httponly' => true,
'cookie_secure' => true,
'cookie_samesite' => 'Lax',
'cookie_lifetime' => 3600,
'gc_maxlifetime' => 3600,
'use_strict_mode' => true,
]);
Session security rules:
- Regenerate session ID after login: Prevent session fixation
- Expire sessions: Idle timeout (30 min) and absolute timeout (24 hr)
- Invalidate on logout: Destroy session server-side
- One session per user (optional): Invalidate old sessions on new login
OAuth 2.0 / OpenID Connect
Use Authorization Code flow with PKCE (Proof Key for Code Exchange). OAuth 2.0 Security Best Practices (RFC 9700 - Jan 2025):
- Short-lived access tokens (15 min) with refresh tokens
- Bind tokens to client (token binding, DPoP)
- Never use Implicit Grant or Resource Owner Password Credentials (ROPC) flow — deprecated in 2025
const crypto = require('crypto');
function generatePKCE() {
const verifier = base64URLEncode(crypto.randomBytes(32));
const challenge = base64URLEncode(
crypto.createHash('sha256').update(verifier).digest()
);
return { verifier, challenge };
}
Common Mistakes
- Storing passwords in plaintext — NEVER. Always hash with Argon2id/bcrypt. Even in development
- Using weak work factors — bcrypt cost < 10 is too fast. Use 12+ (target 200-500ms hash time)
- Predictable session IDs — Use cryptographically secure random. NOT sequential IDs or MD5(username)
- Not invalidating sessions on logout — Client-side cookie deletion is insufficient. Destroy session server-side
- Credentials in URLs —
https://site.com/login?password=123exposes passwords in logs, browser history, referer headers - No password complexity requirements — Require minimum length (12+ chars). Check against compromised password lists
- Weak account recovery — Email-based password reset must use time-limited, single-use tokens. No security questions
See Also
- Previous: CSRF Prevention | Next: Authorization and Access Control
- Reference: OWASP Authentication Cheat Sheet
- Reference: OWASP Password Storage Cheat Sheet
- Reference: OAuth 2.0 Security BCP RFC 9700