My Rules For Web Auth & Session

August 10, 2026 Melalew Mengistu 19 min read

I used to treat a login page like a trophy. Every few months I'd build a new one — sometimes copying a YouTube tutorial, sometimes reinventing it myself — and I'd feel accomplished once the username and password fields posted to some endpoint and redirected on success. They were all static frontends with no real backend behind them, so I never had to think about what happened after the redirect. I just kept collecting login pages like they were proof I understood security.

That illusion broke when I finally faced my fear of backend development and started learning PHP. An idea came up for a school management system — real users, real data, real consequences if someone got in who shouldn't. I built the whole thing, but the login page felt suspicious. Not the UI; the logic behind it. I couldn't explain why it was safe. So I asked ChatGPT how to verify whether my login was actually strong, and the response listed things I'd never considered: session fixation, cookie flags, token storage, expiration strategies. That was the first time I realized authentication is not a page. It's an entire lifecycle that starts at the login form and doesn't end until the user explicitly logs out or the session dies.

Authentication and session management flow
Session management flaws are often invisible until they are actively exploited.
What This Guide Actually Covers

This isn't a "hash your passwords" tutorial. I'm walking through exactly what happens after a user clicks "log in", why the Authorization header uses that specific word instead of Authentication, how sessions and cookies work together to keep a user logged in, the difference between session-based and token-based approaches, and the exact patterns I use to handle sessions securely without leaving tokens exposed to JavaScript or attackers.

Authentication Is the Gate; Authorization Is the Room

The first thing that confused me was the HTTP Authorization header. If it's used to send a token or session ID with every request, why isn't it called Authentication? The distinction matters more than naming pedantry.

Authentication is the act of proving who you are. It's the login form checking your password against a hash. It happens once per session, at the boundary. Authorization is the act of deciding what you're allowed to do once you're inside. The server receives your token or session cookie on every request and asks: "I know who this is — but do they have permission to access this resource?"

That's why the header is called Authorization. By the time the request hits your API, authentication is already done. The header carries proof of identity so the server can make an authorization decision. Mixing the two words up is common, but keeping them straight helps you design better systems. Authentication verifies identity; authorization governs access.

How Session-Based Authentication Actually Works

When a user logs in with valid credentials, the server creates a session — a record stored in memory, a file, or a database that says "User 42 is logged in from this browser." The server generates a unique session ID, a long random string that acts as a reference key. That session ID is sent to the browser inside a cookie, which is a small piece of data the browser stores and automatically attaches to every subsequent request to the same domain.

On every future request, the browser sends the cookie containing the session ID. The server looks up that ID in its session store, finds the associated user, and proceeds with the request. The user never has to log in again until the session expires or they clear their cookies. This is the traditional model used by PHP's native $_SESSION, Django, Rails, and countless frameworks.

PHP: Insecure Session Start
session_start();
$_SESSION['user_id'] = $userId;
// Cookie sent without flags — readable by JavaScript,
// sent over HTTP, and vulnerable to CSRF

Where Insecure Session Handling Creeps In

The session model is sound, but the defaults in most environments are dangerous. I learned this the hard way while auditing my school management system. PHP's session_start() out of the box sends a cookie with no security flags, meaning three separate attacks become possible.

Trap 1: Cookies Without HttpOnly

If a cookie doesn't have the HttpOnly flag, JavaScript can read it via document.cookie. That means any XSS vulnerability — even a tiny one — lets an attacker steal the session ID and impersonate the user. A session ID is the unique token the server issues after authentication that the browser sends back with every request to prove the user is still logged in.

Trap 2: Cookies Without Secure

Without the Secure flag, the browser sends the session cookie over plain HTTP connections. If a user visits your site on an unsecured network or through an HTTP link, the cookie travels in cleartext and can be intercepted.

Trap 3: Storing Tokens in localStorage

When I first heard about JWTs and token-based authentication, I saw tutorials storing the token in localStorage and attaching it to the Authorization header manually. This feels clean because you control every step, but localStorage is fully accessible to JavaScript. Any XSS payload can read it and exfiltrate the token. I treat localStorage as permanently compromised for anything sensitive.

localStorage is not a vault. It has no expiration, no domain restriction beyond the origin, and no protection from JavaScript. If you store an authentication token there, you are one reflected XSS away from account takeover.

How I Handle Sessions Now

I split my approach based on the architecture. For traditional server-rendered apps, I use server-side sessions with hardened cookies. For SPAs and mobile apps talking to APIs, I use short-lived access tokens with refresh token rotation — but I still avoid localStorage.

Server-Side Sessions with Hardened Cookies

This is still my default for anything where I control the backend and the frontend lives on the same domain. The session stays on the server; the browser only holds an opaque session ID.

PHP: Secure Session Configuration
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
$_SESSION['user_id'] = $userId;
$_SESSION['last_activity'] = time();

HttpOnly hides the cookie from JavaScript. Secure enforces HTTPS transmission. SameSite=Strict prevents the browser from sending the cookie on cross-site requests, which blocks CSRF attacks. use_strict_mode rejects uninitialized session IDs, preventing session fixation. A CSRF attack tricks an authenticated user into making unintended requests to a site they're already logged into.

Token-Based Auth Without localStorage

When I need stateless APIs — for React frontends, mobile apps, or microservices — I use JWT access tokens with short expiration times (15–30 minutes) and HTTP-only cookie-based refresh tokens. The access token can live in memory on the client, which means it vanishes on page reload and is never persisted where XSS can grab it. The refresh token lives in an HttpOnly, Secure, SameSite=Strict cookie, so the client never touches it directly.

Node.js: Setting a Secure Refresh Token Cookie
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  path: '/api/auth/refresh'
});

Session Expiration and Rotation

A session that lives forever is a stolen session waiting to happen. I set absolute expiration times — even for "remember me" flows — and I rotate session IDs on privilege changes like password updates or role elevation. If a user changes their password, every existing session should be invalidated immediately. Otherwise, an attacker who stole the old session ID stays logged in despite the password change.

Regenerate session IDs on login. If a user logs in and the server keeps the same session ID they had as a guest, an attacker who knew the pre-login ID now owns an authenticated session. Always call session_regenerate_id(true) in PHP or equivalent in your framework immediately after successful authentication.

Production Checklist

Authentication and session management aren't features you bolt on at the end. These are the four rules I hold every project to before I ship:

  • Never store session tokens or credentials in localStorage; use HttpOnly, Secure, SameSite=Strict cookies instead
  • Regenerate session IDs immediately after login and invalidate all sessions on password change or suspicious activity
  • Set absolute expiration on every session and refresh token; "keep me logged in" should still have a hard ceiling
  • Separate authentication (proving identity) from authorization (enforcing permissions) in your middleware and mental model

None of this makes an app unhackable, and I don't treat it that way. Building login pages was the easy part. Keeping users safely logged in, properly logged out, and correctly restricted at every endpoint is where the real work lives. Security is a process I keep working through, not a milestone I hit once and move past.

The Anatomy of a Stolen Session

Let me walk through exactly what happens when a session token gets intercepted. Not abstractly — step by step, so it's clear why the cookie flags I covered earlier matter.

Alice logs into a web app on a coffee shop's open Wi-Fi. Her browser sends her username and password over HTTPS (good), and the server issues a session cookie. But if that session cookie doesn't have the Secure flag, the browser will happily send it over HTTP too. An attacker on the same network running a proxy tool intercepts an HTTP redirect and captures the cookie value. Now the attacker has Alice's session ID.

The attacker pastes that session ID into their own browser — using a browser extension to manually set the cookie value. They navigate to the app. The server receives the valid session ID, looks it up, finds Alice's user record, and serves the page as Alice. No password needed. The attacker is now Alice from the server's perspective, and they'll stay Alice until the session expires or Alice logs out from another device. This is session hijacking, and the entire attack chain requires zero exploitation of a complex vulnerability — just a missing cookie flag.

Session invalidation after logout is not optional. If the server doesn't invalidate the session ID server-side when Alice logs out, the attacker's copy of the session remains valid indefinitely. Clearing a cookie client-side without server-side invalidation changes nothing for the attacker.

JWT Deep Dive

JSON Web Tokens are everywhere in modern auth, and they're also widely misunderstood and misimplemented. A JWT is a signed token with three parts: a header specifying the algorithm, a payload containing claims (like user ID and expiry), and a signature. The server signs the token when it issues it; on every subsequent request, the server verifies the signature to confirm the token is authentic.

The alg:none Attack

The most catastrophic JWT vulnerability is the alg:none attack. The JWT spec technically allows the algorithm field to be set to none, meaning the signature is omitted. If a vulnerable JWT library sees "alg": "none" in the header, it skips signature verification entirely. An attacker can change the payload to claim admin rights, set alg to none, and the server accepts it as valid.

JSON: Manipulated JWT Header
// Original header
{"alg": "HS256", "typ": "JWT"}

// Attacker modifies to:
{"alg": "none", "typ": "JWT"}

// Attacker modifies payload to escalate privileges:
{"sub": "user123", "role": "admin", "exp": 9999999999}

// The forged token has no signature — vulnerable libraries accept it
// Fix: explicitly reject "none" algorithm in your JWT library config

Weak Secrets and exp Validation

HS256 JWTs use a shared secret for signing. If that secret is weak — like the word "secret" or "password123" — an attacker can brute-force it offline using tools like hashcat. Once the secret is known, they can forge any token. I use cryptographically random secrets of at least 256 bits, generated with a CSPRNG. I also always validate the exp claim server-side. Accepting expired tokens is common when developers skip claim validation after adding JWT support.

JWTs Are Not Sessions

The biggest conceptual mistake I see: treating JWTs as sessions. A server-side session can be invalidated immediately — just delete the session record. A JWT cannot be revoked before it expires unless you maintain a token blocklist, which eliminates the stateless benefit. This is why I use short expiry times (15–30 minutes) for access tokens. A stolen JWT with a 30-minute expiry is a limited-time problem. A stolen JWT with a 30-day expiry is a month-long breach.

OAuth 2.0 and PKCE

OAuth 2.0 is the standard authorization framework behind "Log in with Google/GitHub/Facebook." Understanding how it works — and where it breaks — is essential for any app using third-party identity providers.

The modern, secure flow is the Authorization Code flow with PKCE (Proof Key for Code Exchange, pronounced "pixy"). Here's how it works: the client generates a random code verifier, hashes it to create a code challenge, and sends the code challenge when initiating the OAuth flow. When the authorization code comes back, the client exchanges it for a token by also sending the original code verifier. The authorization server verifies the hash matches. This prevents an intercepted authorization code from being used by anyone who doesn't have the original code verifier.

JavaScript: PKCE Code Challenge Generation
// Generate code verifier (random, 43-128 chars)
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

// Create SHA-256 hash as code challenge
async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

The implicit flow — which returned the access token directly in the URL fragment — is now deprecated. The URL fragment is visible in browser history, server logs, and referrer headers. Tokens in URLs are tokens exposed by accident. Never use the implicit flow for anything new.

Multi-Factor Authentication Implementation

Passwords alone are a broken assumption. People reuse them, they get phished, they show up in breach databases. MFA is the layer that makes stolen passwords useless without a second factor.

TOTP (Time-Based One-Time Passwords)

TOTP is the mechanism behind Google Authenticator, Authy, and similar apps. The server and client share a secret key. Every 30 seconds, both independently compute a 6-digit code from the current timestamp and that secret using HMAC-SHA1. The codes match because they used the same input. The code is valid for one 30-second window (sometimes ±1 window for clock drift). Libraries like speakeasy (Node.js) or pyotp (Python) implement TOTP in a few lines.

WebAuthn and Passkeys

WebAuthn is the future of authentication. Instead of a shared secret, it uses public-key cryptography. The device generates a key pair; the public key is stored on the server, the private key never leaves the authenticator (hardware key, TouchID, FaceID). Authentication is a challenge-response: the server sends a challenge, the authenticator signs it with the private key, the server verifies with the stored public key. There's no password to steal, no phishing attack that works, and no credential database worth breaching.

SMS Weaknesses

SMS-based 2FA is better than nothing, but it's the weakest MFA option. SIM swapping — convincing a carrier to move a phone number to an attacker's SIM — is a documented, recurring attack. SS7 protocol vulnerabilities allow sophisticated attackers to intercept SMS messages at the network level. I treat SMS MFA as a legacy option I keep for users who won't use an authenticator app, not as a strong security control.

Password Storage

If your database is ever breached, password storage is the last line of defense. The question is: how long does it take an attacker to crack the passwords after they have the hashes?

Why MD5 and SHA1 Are Catastrophically Wrong

MD5 and SHA1 were designed for speed. On modern hardware, a single GPU can compute billions of MD5 hashes per second. A hash of "password123" with MD5 is cracked in milliseconds against a rainbow table — a precomputed lookup of hashes to plaintexts. Using MD5 or SHA1 for password storage is equivalent to storing passwords in plaintext for any attacker with a GPU. I've seen production codebases as recently as 2024 still using md5($password).

bcrypt, Argon2, and scrypt

All three of these are designed specifically for password hashing. They're intentionally slow and memory-intensive, making brute-force attacks expensive. bcrypt has a configurable work factor; at factor 12, hashing takes ~250ms on a modern server — trivial for a single login but brutal for bulk cracking. Argon2 won the 2015 Password Hashing Competition and is the modern recommendation; it has parameters for time cost, memory cost, and parallelism. scrypt is similar to Argon2 and used by some major platforms.

Node.js: Secure Password Hashing with bcrypt
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12; // Adjust up as hardware gets faster

// Hashing on registration
async function hashPassword(plaintext) {
  return bcrypt.hash(plaintext, SALT_ROUNDS);
}

// Verification on login
async function verifyPassword(plaintext, storedHash) {
  return bcrypt.compare(plaintext, storedHash);
  // Returns true if match, false otherwise
  // Timing-safe by design — no need for constant-time comparison
}

Key insight: bcrypt automatically generates and stores a unique salt per password. You never need to manage salts separately. The stored hash string contains the algorithm, cost factor, salt, and derived key — everything needed for verification.

Melalew Mengistu

Melalew Mengistu

Web engineer and web security specialist. Helps teams build and ship secure applications.