Secure Authentication 2026: Passwords, Argon2, Sessions, JWT

Secure authentication: passwords, hashing, sessions and JWT
Why your password sucks and how to fix it (without losing your mind)
date: · 18 min
author: Matteo

These past few days I’ve had the pleasure — and I use the term loosely — of diving headfirst into authentication systems. Login, registration, session management, password reset: the classic full package. One of those jobs that tends to get underestimated in a company because “someone already handled this before us”, until you discover that said someone had some very creative ideas about security.

I’d open Stack Overflow for a quick reference and find 2012 answers still recommending MD5 as a valid solution. I’d look at the legacy code I was supposed to build on top of and find passwords stored in plain text, complete with a // TODO: add hashing comment. The TODO was from 2011. Fifteen years later, those passwords were still sitting there, naked and happy, in the production database.

So instead of quietly fuming, I decided to write up everything I’ve been revisiting these days. Next time, I’ll just send the link.

The problem with “complex” passwords

Let’s start with something that sounds counterintuitive: the classic rules for password creation are almost useless.

You know the drill — “use at least one uppercase letter, one number, one symbol and the tears of a certified unicorn”. It doesn’t protect you as much as you think. Why? Because it backs the user into a corner, and they respond in the most human way possible: they write P@ssw0rd1 on a post-it stuck to their monitor, or they reuse the same password everywhere.

Result: a password that looks strong but is effectively useless.

What actually works? Length. Requiring a minimum number of characters is far more effective than demanding symbol gymnastics. NIST’s guidelines (SP 800-63B, revision 4, 2025) require at least 15 characters when the password is the only authentication factor, 8 when there is a second factor too, and accepting at least 64 characters without truncating them. A passphrase like purple-train-monday-pizza is statistically stronger than Purp1e! and infinitely easier to remember.

The same guidelines make one more check mandatory: compare the password against a list of common or previously breached passwords. If it’s on the list, reject it. You don’t need to host the list yourself: services like Pwned Passwords are queried by sending only the first 5 characters of the SHA-1 hash (k-anonymity), so the password never leaves your server.

The other thing worth checking is structural predictability. A password like 12345678 or qwertyui easily clears an 8-character minimum, yet it’s terrible. How do you catch it? With a couple of interesting techniques.

The first is run-length compression: if you try compressing the password with RLE and the result is extremely short, the password is made of repeated characters (aaaaaaaa → a8). Bad sign.

The second is first derivative analysis: compute the absolute difference between the ASCII values of consecutive characters, then check whether that sequence is compressible. If the password is ABCDEF or 12345678, all differences are 1, so the derivative compresses trivially. This way you catch predictable sequences even when the characters aren’t identical. It does not work for keyboard walks like qwertyui or asdfgh, though: key order is not ASCII order, and the differences come out scattered. For those you need a pattern dictionary, like the zxcvbn library uses, or the common-password list above, where qwerty and friends are certainly present.

In practice, rather than blocking users with byzantine rules, you can give them useful feedback: “this password is too predictable, try something more random”. Passwords on the list must be rejected; for the merely “weak” ones you can decide whether to block or just warn. The important thing is that you know.

Hashing: never store passwords in plain text

Passwords are not stored. Ever. They are transformed through a hashing function — a one-way process that produces a fixed-length fingerprint from which the password cannot be recovered. Even if someone steals your database, they don’t see the original passwords. They only see the hashes.

But not all hashing algorithms are fit for the job. Fast algorithms like SHA-1 or MD5 were designed to verify file integrity, not to protect passwords. They’re so quick that a modern GPU cluster can test billions of combinations per second.

For passwords you need intentionally slow algorithms, designed to make every brute force attempt expensive.

Bcrypt: the old reliable

For years Bcrypt was the de facto standard. It still works — OWASP considers it acceptable for existing systems, with a cost of at least 10 (since PHP 8.4 the default is 12) — but it has two limitations.

The first: it’s slow on the CPU, but it doesn’t need much memory. An attacker with many GPUs or dedicated hardware can parallelise better than against Argon2. And no cost factor saves a trivial password: those fall within a few guesses anyway.

The second, often overlooked: Bcrypt only considers the first 72 bytes of the password. PHP silently truncates the rest, while the Python bcrypt library raises a ValueError since version 5.0. With long passphrases and accented characters (which take more than one byte in UTF-8), it’s a limit worth knowing.

<?php
// PHP - Hashing with Bcrypt (note: only the first 72 bytes count)
$password = 'the_user_password';
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Verification
if (password_verify($password, $hash)) {
    echo "Password correct!";
}
# Python - Hashing with Bcrypt (over 72 bytes, since 5.0: ValueError)
import bcrypt

password = b'the_user_password'
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password, salt)

# Verification
if bcrypt.checkpw(password, hashed):
    print("Password correct!")

Argon2: the current gold standard

Argon2 won the Password Hashing Competition in 2015 and is today considered the best available algorithm; the variant to use for passwords is Argon2id. The key difference from Bcrypt is memory-hardness: you can configure it to require a lot of memory to compute each single hash.

GPUs have enormous parallel processing power, but per-computation memory is their bottleneck: the more memory each hash needs, the fewer guesses an attacker can run in parallel. On a web server, though, you can’t go overboard, because every login in progress holds that memory: with a few gigabytes per hash, a handful of simultaneous logins is enough to bring your own server to its knees. Typical values range from 19 MiB (OWASP’s minimum: m=19 MiB, t=2, p=1) to 64 MiB (the RFC 9106 profile for memory-constrained environments: t=3, p=4).

The three configurable parameters are:

  • Memory: how much RAM each computation requires
  • Iterations: how many times the process repeats
  • Parallelism: how many CPU cores it can use

The practical goal is for each authentication to take between 100 and 500 milliseconds on your server. For the user, it’s imperceptible; for an attacker who needs to try millions of combinations, it multiplies the cost of every guess. It won’t save a trivial password, though: password123 still falls on the first dictionary pass, which is why you also need the common-password list.

<?php
// PHP - Hashing with Argon2id (available since PHP 7.3)
$password = 'the_user_password';
$options = [
    'memory_cost' => 65536, // 64 MiB
    'time_cost'   => 4,     // 4 iterations
    'threads'     => 1,     // >1 only if PHP uses libargon2: with libsodium it errors
];
$hash = password_hash($password, PASSWORD_ARGON2ID, $options);

// Verification (identical API to Bcrypt)
if (password_verify($password, $hash)) {
    echo "Password correct!";
    // If you have changed algorithm or parameters in the meantime, update the hash
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID, $options)) {
        $hash = password_hash($password, PASSWORD_ARGON2ID, $options); // and save it to the DB
    }
}
# Python - Hashing with Argon2id (pip install argon2-cffi)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()  # default: RFC 9106 low-memory profile (64 MiB, t=3, p=4)

hashed = ph.hash('the_user_password')

# Verification
try:
    ph.verify(hashed, 'the_user_password')
    print("Password correct!")
    if ph.check_needs_rehash(hashed):  # parameters changed: update the hash in the DB
        hashed = ph.hash('the_user_password')
except VerifyMismatchError:
    print("Wrong password.")

Note: only VerifyMismatchError is caught, not Exception. A corrupted hash or a configuration error should surface, not turn into a generic “wrong password”.

The Salt: why every hash must be unique

Even with Argon2, there’s one non-negotiable detail: the salt. It’s a random string generated by a CSPRNG (Cryptographically Secure Pseudo-Random Number Generator) — in PHP random_bytes(), in Python the secrets module, which in turn use the operating system’s getrandom() or /dev/urandom — and generated afresh for every hash, so also every time the user changes their password.

The salt does two fundamental things.

First: if two users choose the same password, their hashes in the database will still be different. Without a salt, an attacker who spots two identical hashes immediately knows those two users share the same password — most likely a common, easy-to-guess one.

Second: it makes rainbow table attacks useless. A rainbow table is a precomputed dictionary of hashes for the most common passwords. With a salt, an attacker would have to redo the calculations separately for every single record in the database, using the specific salt of that hash.

The good news is that both password_hash() in PHP and the Argon2/Bcrypt libraries in Python handle the salt automatically. You don’t compute it yourself — it’s already embedded in the resulting hash string.

<?php
// The salt is managed automatically and embedded in the hash
$hash = password_hash('my_password', PASSWORD_ARGON2ID);
// $hash contains: algorithm + parameters + salt + hash
// Example (PHP defaults): $argon2id$v=19$m=65536,t=4,p=1$<salt_base64>$<hash_base64>

Session IDs and Cookies: managing the session after login

Once the user has successfully authenticated, you need to keep them authenticated as they navigate. This is where the session comes in.

The mechanism is straightforward: the server generates a unique session ID, associates it with the user in the database, and sends it to the browser via a cookie. On every subsequent request, the browser sends the cookie back and the server verifies that the ID is still valid.

Generating a secure session ID

The session ID must be completely unpredictable. It needs at least 128 bits of entropy, generated by a CSPRNG. If it were guessable, an attacker could figure it out and take over another user’s session.

And it must be new at every login: never reuse or “promote” an ID that existed before login, otherwise an attacker can get you to use an ID they already know (session fixation). The same applies after every privilege change, including passing 2FA. If you use PHP’s native sessions instead of your own table, the function is session_regenerate_id(true).

In the database, it’s also worth storing only the hash of the ID, as we’ll see for reset tokens: if someone reads the table (a dump, an SQL injection), they find no still-valid sessions to reuse.

<?php
// PHP - Generating a secure session ID
$session_id   = bin2hex(random_bytes(32));     // 256 bits → 64 hex characters, goes in the cookie
$session_hash = hash('sha256', $session_id);   // only this goes in the database

$stmt = $pdo->prepare("
    INSERT INTO sessions (session_hash, user_id, created_at, last_activity)
    VALUES (?, ?, NOW(), NOW())
");
$stmt->execute([$session_hash, $user_id]);
# Python - Generating a secure session ID
import datetime
import hashlib
import secrets

session_id = secrets.token_hex(32)  # 256 bits, goes in the cookie
session_hash = hashlib.sha256(session_id.encode()).hexdigest()  # only this goes in the database
now = datetime.datetime.now(datetime.timezone.utc)

# Storing in the database (SQLAlchemy example)
new_session = Session(
    session_hash=session_hash,
    user_id=user_id,
    created_at=now,
    last_activity=now
)
db.add(new_session)
db.commit()

Once the ID is generated, sending it to the browser with the right flags is critical:

<?php
setcookie('session_id', $session_id, [
    'expires'  => time() + 86400, // 24 hours
    'path'     => '/',
    'secure'   => true,       // HTTPS only
    'httponly' => true,       // Not readable by JavaScript (limits the damage of XSS)
    'samesite' => 'Lax',      // Mitigates CSRF (does not replace CSRF tokens)
]);
  • HttpOnly prevents JavaScript from reading the cookie. If an attacker manages to inject JS into the page (XSS attack), they can’t walk away with the cookie; they can still act as the user while the compromised page is open, though. It limits the damage; it doesn’t replace XSS prevention.
  • Secure ensures the cookie only travels over HTTPS. Without this flag, the cookie flies in plain text over unencrypted networks.
  • SameSite=Lax doesn’t send the cookie on requests coming from other sites, except top-level navigations with “safe” methods like GET (a link click, but also a GET form or a redirect). It greatly reduces CSRF risk but doesn’t eliminate it: no state changes via GET, and on POST requests a CSRF token or at least an Origin header check. Also, “site” means registrable domain, not origin: a compromised subdomain counts as the same site.

Managing session expiry

A session shouldn’t last forever. You need to handle two types of timeout:

  • Absolute lifetime: the session expires after X hours from login, even if the user is active
  • Inactivity timeout: the session expires if the user does nothing for Y minutes

When a session expires — or the user logs out — you must delete it from the database, not just from the browser. Otherwise anyone who intercepted or stole the cookie can keep using it undisturbed.

<?php
// Logout: delete the session from the database AND from the browser
function logout(PDO $pdo, string $session_id): void
{
    $stmt = $pdo->prepare("DELETE FROM sessions WHERE session_hash = ?");
    $stmt->execute([hash('sha256', $session_id)]);

    setcookie('session_id', '', [
        'expires'  => time() - 3600,
        'path'     => '/',
        'secure'   => true,
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

// Password reset: invalidate ALL active sessions for the user
function invalidate_all_sessions(PDO $pdo, int $user_id): void
{
    $stmt = $pdo->prepare("DELETE FROM sessions WHERE user_id = ?");
    $stmt->execute([$user_id]);
}

Server-side sessions or JWT: which one to choose?

JSON Web Tokens are everywhere and are often presented as the modern solution for everything. The reality is more nuanced. And one clarification: a JWT can perfectly well travel inside a cookie, so the real choice isn’t “cookie or JWT”, but server-side session (the cookie only holds an ID) or self-contained token (the token already holds the data).

A JWT is a signed token — with a digital signature such as RS256 or ES256, or with a shared-key HMAC such as HS256 — that already contains the user’s data in its payload. The server doesn’t need to query the database to know who you are: it verifies the signature and reads the payload. This is its big advantage in distributed architectures.

Be careful, though: the payload is only base64url-encoded, not encrypted. Anyone holding the token can read it, so no sensitive data in there. And when verifying, never trust what the token says about itself: pin the algorithm yourself (this rules out alg: none and algorithm confusion attacks) and check issuer, audience and expiry, as RFC 8725 recommends.

# Python - Verifying a JWT with PyJWT (pip install pyjwt)
import jwt

payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],  # algorithm pinned by you, never read from the token
    audience="api.yoursite.com",
    issuer="https://auth.yoursite.com",
    options={"require": ["exp", "iss", "aud", "sub"]},
)

The problem with JWTs for browser user sessions is that they can’t be easily revoked. If a token has a 24-hour validity and the user logs out or changes their password, that token still exists and works until its natural expiry. Unless you implement a blacklist, which at that point brings you back to querying the database and you lose the main advantage. The standard answer is a short-lived access token (5–15 minutes) paired with a refresh token that is replaced on every use: if someone tries to reuse an old one, the server notices and invalidates the whole chain.

The practical rule of thumb:

ScenarioRecommended solution
User sessions in the browserSession cookie + database
Authentication between APIs and servicesShort-lived JWT
Mobile app calling your APIShort-lived access tokens + rotated refresh tokens
Single Page Application with separate backendHttpOnly session cookie managed by the backend (BFF pattern); if you need tokens, short-lived access tokens + rotated refresh tokens, never in localStorage

For most “classic” web applications — PHP or Python backends rendering HTML — session cookies are the simplest, most efficient and most secure choice. JWTs shine instead with microservices, public APIs, or architectures where multiple servers need to validate requests without coordinating with each other.

Password reset: an underestimated attack vector

Implementing reset via email means that access to the user’s inbox is equivalent to access to your service. If the email is compromised, all your Argon2 mechanisms and secure cookies become worthless: the attacker simply requests a reset link.

This doesn’t mean you shouldn’t implement it — you have to — but it must be treated with the same seriousness as the primary login:

  • The reset link must have a short expiry (15–30 minutes maximum)
  • The token in the link must be generated with a CSPRNG, not with rand() or microtime()
  • After use, the token must be invalidated immediately
  • The reset must invalidate all active sessions for the user
  • The form always answers the same way (“if the address is registered, you’ll receive an email”), whether the email exists or not: otherwise it becomes a handy way to find out who has an account
  • The reset page sends the Referrer-Policy: no-referrer header, so the token in the URL doesn’t leak through the Referer to external sites
<?php
// Secure reset token: sent in plain text in the URL, stored hashed in the DB
// This way even a database dump doesn't expose still-valid tokens
$reset_token = bin2hex(random_bytes(32));
$token_hash  = hash('sha256', $reset_token);

// One valid token per user: delete the previous one and insert the new one.
// The database computes the expiry, like NOW() in the sessions: no timezone mismatch between PHP and DB
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ?")->execute([$user_id]);
$stmt = $pdo->prepare("
    INSERT INTO password_resets (user_id, token_hash, expires_at)
    VALUES (?, ?, NOW() + INTERVAL 30 MINUTE)
");
$stmt->execute([$user_id, $token_hash]);

$reset_url = "https://yoursite.com/reset-password?token={$reset_token}";
// Send $reset_url to the user via email

Two-Factor Authentication (2FA): the second lock

2FA adds a second verification layer based on possession of something, not just knowledge of something. Even if the attacker knows the password, they still need the user’s physical device.

The main options:

Passkeys (WebAuthn/FIDO2): the private key stays on the device or in the system keychain (iCloud, Google, a password manager) and is bound to the site’s domain, so a phishing page can’t use it. They’re supported by all major operating systems and browsers, they’re the most secure option available at scale, and they can even replace the password entirely. NIST’s guidelines require that, at the assurance level typical of an account with 2FA, at least one phishing-resistant option like this be offered.

Hardware keys (YubiKey and similar): physical USB or NFC devices based on the same FIDO2 standard. Extremely secure and phishing-resistant, but mostly found in corporate environments or among particularly security-conscious users.

Authenticator apps (TOTP): Google Authenticator, Authy and similar apps generate time-based codes from a shared secret exchanged via QR code. A good, widespread compromise, but not resistant to real-time phishing: a fake page can ask for your code and forward it straight to the real site.

SMS: the most widespread and user-friendly method, but also the weakest. There’s SIM swapping — talking the carrier into moving your number to the attacker’s SIM, a cheap and widespread social engineering attack — plus real-time phishing. NIST classifies it as a “restricted” method: fine as a fallback, not as the only option. Still better than nothing.

# Python - TOTP with pyotp (pip install pyotp)
import datetime
import pyotp

# Generate the secret: store it encrypted in the database
# (it can't be hashed: it's needed in clear to verify codes)
secret = pyotp.random_base32()

# URI for configuring Google Authenticator (convert to QR code)
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(
    name="matteo@example.com",
    issuer_name="My Service"
)

# Verify the code entered by the user
user_code = "123456"
now = datetime.datetime.now(datetime.timezone.utc)
step = totp.timecode(now)  # number of the current 30-second window

if user.last_totp_step is not None and step <= user.last_totp_step:
    print("Code already used, wait for the next one.")  # anti-replay (RFC 6238)
elif totp.verify(user_code, for_time=now, valid_window=1):  # tolerates ±30 s of clock drift
    user.last_totp_step = step  # save this to the database
    print("Valid code, access granted!")
else:
    print("Invalid code.")  # and count attempts: after a few failures, lock for a while

The code checks three things the “minimal” version usually forgets: the same code can’t be used twice (RFC 6238 forbids it), a slightly skewed clock doesn’t lock the user out, and attempts are limited, otherwise a million combinations get tried quickly.

Two details that often get overlooked. First: between a correct password and the OTP code, the user doesn’t have a real session yet. Keep a temporary “password ok, second factor pending” state with an expiry of a few minutes and an attempt limit, and create the session (with a new ID) only after the second factor. Second: the password reset flow should require 2FA verification if it’s enabled on the account. Otherwise you have a nice second factor on the front door and a completely wide-open back entrance.

Putting it all together: the complete flow

To recap, a sensible authentication system in 2026 works like this:

  1. Registration: user picks a password → you validate it (at least 15 characters, or 8 with 2FA; not on the common or breached password list; not trivially predictable) → hash it with Argon2id (salt included) → store only the hash in the database. Never the plain text password.

  2. Login: user submits username and password → retrieve the hash from the database → verify with password_verify() (and, if you changed parameters, update the hash with password_needs_rehash()) → if valid, generate a new session ID with CSPRNG (128+ bits, never the pre-login one), store its hash in the database with an expiry, send it as an HttpOnly + Secure + SameSite cookie.

  3. Session: on every request, the browser sends the cookie → hash the ID and check that it exists in the database and hasn’t expired → update the last_activity timestamp.

  4. Logout: delete the session from the database and clear the cookie.

  5. Password reset: generate a secure token with a short expiry → send it via email (only its hash in the DB), answering the same way whether the email exists or not → on link click, verify the token and, if the account has 2FA, the second factor too → change the password with a new Argon2id hash and invalidate all active sessions.

  6. 2FA: after a correct password, ask for the second factor (ideally a passkey or a FIDO2 key) before creating the session. With passkeys you can even do without the password.

None of this is rocket science, but it requires attention to detail. The kind of detail that, apparently, someone back in 2011 had deferred to a TODO that never got resolved.


In the next episode of corporate developer adventures: legacy code, tables with no foreign keys, and that particular mix of dread and resignation when you run SHOW CREATE TABLE and see ENGINE=MyISAM. Stay tuned.

Copied