Skip to main content
Secure Authentication and Session Best Practices

Secure Authentication and Session Best Practices

KU
• 6 min read•38 views

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.

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.

python.python
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.
python.python
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: 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.
  • 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), and iss (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.
python.python
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.

Explore topics:DevOps
React
Share this post
KU

Kiril Urbonas

AI Engineer

553 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.