Secure Authentication in 2026: Passwords, Hashing, Sessions and JWT Done Right

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

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 — today the sweet spot is 10, 11 or 12 — is far more effective than demanding symbol gymnastics. A passphrase like purple-train-monday-pizza is statistically stronger than Purp1e! and infinitely easier to remember.

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 two 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 (aaaaaaaaa8). 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, all differences are 1, so the derivative compresses trivially. Same goes for keyboard walk patterns like asdfgh. This way you catch predictable sequences even when the characters aren’t identical.

In practice, rather than blocking users with byzantine rules, you can give them useful feedback: “this password is too predictable, try something more random”. Whether you let them proceed anyway is a product decision. 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 unique binary string. 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 and it’s better than nothing, but it has a limitation: it’s primarily designed to be slow on the CPU, while modern GPUs can still parallelise attacks quite effectively. With powerful enough hardware, passwords up to eight characters protected by Bcrypt can be cracked.

<?php
// PHP - Hashing with Bcrypt
$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
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 an international password hashing competition in 2015 and is today considered the best available algorithm. The key difference from Bcrypt is memory-hardness: you can configure it to require gigabytes of RAM to compute a single hash.

GPUs have enormous parallel processing power, but they handle large memory access poorly when doing per-thread computations. Argon2 exploits exactly this weakness: with 2 GB of RAM required per hash, even a massive GPU cluster grinds to a halt.

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’s an insurmountable wall.

<?php
// PHP - Hashing with Argon2id (available since PHP 7.3)
$password = 'the_user_password';
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // 64 MB
    'time_cost'   => 4,     // 4 iterations
    'threads'     => 2,     // 2 parallel threads
]);

// Verification (identical API to Bcrypt)
if (password_verify($password, $hash)) {
    echo "Password correct!";
}
# Python - Hashing with Argon2 (pip install argon2-cffi)
from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=4,
    memory_cost=65536,  # 64 MB
    parallelism=2
)

hashed = ph.hash('the_user_password')

# Verification
try:
    ph.verify(hashed, 'the_user_password')
    print("Password correct!")
except Exception:
    print("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) — essentially /dev/random on the operating system — associated uniquely with each user before computing the hash.

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 impossible. 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 that specific user’s salt.

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: $argon2id$v=19$m=65536,t=4,p=2$<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, stores it in the database associated with the user, 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.

<?php
// PHP - Generating a secure session ID
$session_id = bin2hex(random_bytes(32)); // 256 bits → 64 hex characters

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

session_id = secrets.token_hex(32)  # 256 bits

# Storing in the database (SQLAlchemy example)
new_session = Session(
    session_id=session_id,
    user_id=user_id,
    created_at=datetime.datetime.now(),
    last_activity=datetime.datetime.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 accessible via JavaScript (protects against XSS)
    'samesite' => 'Lax',      // Protects against CSRF
]);
  • HttpOnly prevents JavaScript from reading the cookie. If an attacker manages to inject JS into the page (XSS attack), they can’t steal the session.
  • Secure ensures the cookie only travels over HTTPS. Without this flag, the cookie flies in plain text over unencrypted networks.
  • SameSite=Lax restricts the cookie to requests originating from the same site or top-level navigation (clicking a link). It blocks automatic cross-site requests, protecting against CSRF.

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 the ID 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_id = ?");
    $stmt->execute([$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]);
}

Cookies vs 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.

A JWT is a digitally signed token 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.

The problem with JWTs for browser user sessions is that they’re heavier than cookies — they have to carry a signature and headers in every request — and most importantly, 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 practical rule of thumb:

ScenarioRecommended solution
User sessions in the browserSession cookie + database
Authentication between APIs and servicesJWT
Mobile app calling your APIJWT
Single Page Application with separate backendJWT (with refresh token)

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
<?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);
$expires_at  = date('Y-m-d H:i:s', time() + 1800); // 30 minutes

$stmt = $pdo->prepare("
    INSERT INTO password_resets (user_id, token_hash, expires_at)
    VALUES (?, ?, ?)
    ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), expires_at = VALUES(expires_at)
");
$stmt->execute([$user_id, $token_hash, $expires_at]);

$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:

Authenticator apps (TOTP): Google Authenticator, Authy and similar apps generate time-based codes based on a shared secret exchanged via QR code. This is the most secure method among the widely adopted ones.

SMS: The most widespread and user-friendly method. Not the most secure in absolute terms — SIM swapping exists — but infinitely better than nothing. The vast majority of attackers don’t have the resources to pull off SIM forgery.

Hard tokens (YubiKey and similar): Physical USB or NFC devices. Extremely secure, but adopted mainly in corporate environments or by particularly security-conscious users.

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

# Generate the secret (save this in the database for the user)
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"
if totp.verify(user_code):
    print("Valid code, access granted!")
else:
    print("Invalid code.")

One detail that often gets overlooked: 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 (minimum length, not too compressible) → hash it with Argon2id + salt → 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() → if valid, generate a session ID with CSPRNG (128+ bits), store it in the database with an expiry, send it as an HttpOnly + Secure + SameSite cookie.

  3. Session: on every request, the browser sends the cookie → check that the ID 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 (stored hashed in the DB) → on link click, verify the token, change the password with a new Argon2 hash, invalidate all active sessions.

  6. 2FA: after a successful password login, ask for the OTP code before creating the session.

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.

content_copy Copiato