A practical guide to hashing passwords, adding MFA, hardening sessions, and avoiding the JWT mistakes that break production auth.
Authentication is where most breaches start, and it is also where teams cut the most corners. The good news is that the correct approach is well understood and mostly boils down to picking the right primitives and refusing to be clever. This post walks through password storage, multi-factor auth, session management, and the JWT traps that keep showing up in incident reports. It sits alongside our broader application security best practices guide.
If you take one thing away: use a purpose-built password hashing function, not a general-purpose digest. MD5 and SHA-1 are fast, and fast is exactly wrong for passwords because it lets an attacker try billions of guesses per second against a stolen table.
Reach for argon2id first. It is memory-hard, which defeats GPU and ASIC cracking rigs, and it is the current OWASP-recommended default. bcrypt remains a perfectly acceptable second choice, especially where an argon2 binding is not available; just be aware of its 72-byte input limit. Both algorithms generate a per-user salt automatically and encode it into the output string, so you do not manage salts yourself.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Parameters here are sane defaults; tune memory_cost to your hardware.
ph = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4)
def hash_password(plaintext: str) -> str:
return ph.hash(plaintext) # returns full encoded string incl. salt + params
def verify_password(stored_hash: str, plaintext: str) -> bool:
try:
ph.verify(stored_hash, plaintext)
except VerifyMismatchError:
return False
# Transparently upgrade if you have raised the cost parameters.
if ph.check_needs_rehash(stored_hash):
# re-hash and persist the new value on successful login
pass
return True
Peppering, a secret value mixed in before hashing and kept outside the database (in a KMS or app config), is an optional extra layer. It helps only if your database leaks but your secret store does not, and it adds operational cost around rotation. Treat it as defense in depth, not a substitute for a strong algorithm.
One rule underlies all of this: do not roll your own crypto. Use a maintained library binding, keep it updated, and let the algorithm do the salting.
A password alone is a single point of failure. Adding a second factor blunts phishing, credential stuffing, and reuse.
TOTP (the six-digit codes from an authenticator app) is a solid baseline and easy to implement with any RFC 6238 library. It is far better than SMS, which is vulnerable to SIM swapping and interception.
The strong option is WebAuthn / passkeys. Because the credential is bound to your origin and the private key never leaves the authenticator, passkeys are phishing-resistant by design: a lookalike domain simply cannot produce a valid assertion. If you are building auth today, plan for passkeys as a first-class path rather than an afterthought.
Once a user authenticates, you need to remember them. Prefer opaque, server-side session identifiers stored in a fast store (Redis, a database) with the ID handed to the client in a cookie. The cookie should carry:
response.set_cookie(
"session",
session_id,
httponly=True,
secure=True,
samesite="Lax",
max_age=60 * 60 * 8, # absolute cap
path="/",
)
Beyond flags, three behaviors matter:
Rotate the session ID on login. Issue a fresh identifier the moment a user authenticates and discard any pre-login ID. This closes session fixation, where an attacker plants a known session ID and waits for the victim to log in with it.
Enforce two timeouts. An idle timeout expires sessions after a period of inactivity; an absolute timeout caps total lifetime regardless of activity. Both should be enforced server-side, not merely by cookie expiry, which the client controls.
Actually invalidate on logout. Logout must delete the session record on the server, not just clear the cookie. If the record survives, a captured ID stays valid.
JSON Web Tokens are useful for stateless service-to-service claims, but they are a frequent source of authentication bugs. The recurring mistakes:
alg: none trap. Some libraries once honored a header claiming no signature, letting anyone forge a token. Always pin the expected algorithm server-side and reject anything else.exp (expiry), aud (audience), and iss (issuer). A valid signature on a token minted for a different service is still the wrong token.import jwt # PyJWT
def verify_token(token: str, secret: str) -> dict:
return jwt.decode(
token,
secret,
algorithms=["HS256"], # pin it; never trust the header's alg
audience="api.example.com", # verify aud
issuer="auth.example.com", # verify iss
options={"require": ["exp", "aud", "iss"]},
)
Online guessing attacks are cheap to run and cheap to defend against. Apply rate limiting per account and per IP on login and MFA endpoints. Add progressive delays or a temporary account lockout after repeated failures, tuned so you frustrate attackers without letting them lock out real users on purpose. For credential stuffing, where attackers replay passwords leaked elsewhere, check submitted passwords against a breached-password corpus and lean on MFA to make valid credentials insufficient on their own.
Reset flows are a common bypass. Generate a high-entropy, single-use token, store only its hash, give it a short expiry, and invalidate it once used or once a new one is requested. Never email the password or a reversible token.
Finally, keep responses generic. "Invalid username or password" beats "no such user," and a reset endpoint should say "if that address exists, we sent a link" regardless of outcome. Distinct responses hand attackers a user-enumeration oracle. Keep timing consistent too, so response latency does not leak the same information.
Use argon2id for passwords, make passkeys your strong MFA path, and keep sessions server-side with HttpOnly, Secure, SameSite cookies that rotate on login. Treat JWTs as short-lived, fully-verified claims with pinned algorithms, never as long-lived credentials in localStorage. And wrap it all in rate limiting and generic errors. None of this is novel, which is the point: authentication rewards discipline over cleverness.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A working engineer's tour of the OWASP Top 10, each risk paired with the concrete fix that actually closes it in production.
A likelihood-ordered checklist for tracing "Permission denied" on Linux through mode bits, ownership, ACLs, SELinux, and mount options.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.