Secure Authentication and Session Best Practices
A practical guide to hashing passwords, adding MFA, hardening sessions, and avoiding the JWT mistakes that break production auth.
Key takeaways
A practical guide to hashing passwords, adding MFA, hardening sessions, and avoiding the JWT mistakes that break production auth.
On this page
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.
Store passwords the boring, correct way#
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.
Make MFA the norm, and make it strong#
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.
Sessions: server-side, and cookies done right#
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:
- HttpOnly: blocks JavaScript from reading the cookie, so XSS cannot exfiltrate the session.
- Secure: the cookie is only sent over HTTPS.
- SameSite=Lax or Strict: limits cross-site sending, cutting off a class of CSRF. Pair this with proper CSRF protection for state-changing requests.
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.
JWT: powerful, and easy to misuse#
JSON Web Tokens are useful for stateless service-to-service claims, but they are a frequent source of authentication bugs. The recurring mistakes:
- The
alg: nonetrap. 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. - Weak or shared secrets. An HMAC key you can guess is a skeleton key. Use a long random secret, or asymmetric keys (RS256/ES256) so verifiers never hold signing material.
- Not verifying the whole token. Verifying the signature is necessary but not sufficient. You must also check
exp(expiry),aud(audience), andiss(issuer). A valid signature on a token minted for a different service is still the wrong token. - Storing JWTs in localStorage. This exposes the token to any XSS on the page. Prefer an HttpOnly cookie, or keep access tokens short-lived and in memory only.
- Revocation is genuinely hard. A signed token is valid until it expires; there is no natural "log this token out." Keep access-token lifetimes short (minutes), use refresh tokens you can revoke server-side, and maintain a denylist if you need immediate cutoff.
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"]},
)
Slow down the guessers#
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.
Password reset and error messages#
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.
The call we'd make#
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 DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Find What's Eating Your RAM on Linux
A practical method for reading free, top, and ps correctly so you attribute Linux memory to a real cause instead of guessing.
Linux "Permission Denied" — Diagnose It Fast
A likelihood-ordered checklist for tracing "Permission denied" on Linux through mode bits, ownership, ACLs, SELinux, and mount options.
More from DevOps
Explore more articles in this category
Kubernetes vs Docker Swarm in 2026: Is Swarm Still Worth It?
Swarm lost the orchestration war years ago, but it's still shipping and still simpler. Here is what that simplicity actually buys you, and what it costs.
Best Kubernetes IDE and GUI Tools in 2026
kubectl is fine until you're juggling five namespaces across three clusters. These are the tools that make that manageable, compared.
Chef vs Puppet vs Ansible: Configuration Management in 2026
One is agentless and Python-based, the other two run a persistent agent and a domain-specific language. The architecture difference matters more than the syntax.
You might have missed
Evergreen posts worth revisiting.