Authentication And Identity Systems

Token Implementation In PHP

The earlier lessons in this track designed authentication flows; this one covers the two concrete mechanisms PHP uses to carry a proven identity across requests — sessions and JWTs — and how to implement each safely. The choice between them is one of the most consequential and most misunderstood decisions in web auth, so start there.

Sessions Versus JWTs

A session stores the state on the server and hands the browser only an opaque session ID in a cookie. Each request, the server looks up the ID to find who the user is. A JWT puts the identity in the token itself — a signed, self-contained claim the server validates without a lookup.

The trade is the mirror image of each other:

  • Sessions are revocable instantly (delete the server record) but require server-side storage that every request consults — trivial on one server, needing shared storage (Redis, database) across a fleet.
  • JWTs are stateless and scale effortlessly (no lookup) but are not revocable before expiry, because nothing is checked against a server record.

The practical guidance is unfashionable but correct: for a server-rendered web application, sessions are usually the better default. They are simpler, revocable, and PHP has them built in. JWTs earn their place for stateless APIs, cross-service auth, and mobile clients — and even then, the access/refresh split pairs a stateless access JWT with a revocable server-side refresh token precisely to buy back the revocability JWTs lack. Reaching for JWTs to log into a monolith is a common cargo-cult mistake.

Sessions Done Right In PHP

PHP's session handling is solid; the security is in the cookie flags and a couple of disciplines:

PHP example
<?php

declare(strict_types=1);

// Set before session_start(), typically in php.ini or via session_set_cookie_params.
$cookieParams = [
    'httponly' => true,   // JavaScript cannot read the cookie (blunts XSS theft)
    'secure'   => true,   // sent only over HTTPS
    'samesite' => 'Lax',  // not sent on cross-site requests (blunts CSRF)
    'path'     => '/',
];

echo 'httponly=' . ($cookieParams['httponly'] ? 'yes' : 'no') . PHP_EOL;
echo 'secure=' . ($cookieParams['secure'] ? 'yes' : 'no') . PHP_EOL;

// Prints:
// httponly=yes
// secure=yes

Three flags carry most of the security: HttpOnly keeps the session ID out of JavaScript's reach so an XSS bug cannot exfiltrate it; Secure keeps it off plaintext HTTP; SameSite stops the browser from attaching it to cross-site requests, which is a strong CSRF defense. Two disciplines complete it: regenerate the session ID on privilege change (session_regenerate_id(true) at login) to defeat session fixation, where an attacker plants a known ID before login and inherits the authenticated session; and set an idle timeout and absolute lifetime so abandoned sessions die. Store sessions in Redis or the database once you run more than one server, or users bounce between servers that do not share session state.

JWTs Done Right In PHP

A JWT has three parts — header, payload, signature — base64url-encoded and dot-joined. The signature is the entire security: it proves the token was minted by you and not altered. Use a maintained library (firebase/php-jwt or lcobucci/jwt), never hand-roll parsing, and observe the non-negotiables:

  • Verify the signature on every request, and pin the algorithm. The infamous JWT vulnerability is the alg: none / algorithm-confusion attack, where an attacker changes the header to claim no signature (or tricks an RS256 verifier into accepting an HS256 token signed with the public key). Always specify the expected algorithm to the verifier rather than trusting the token's own header.
  • Validate the standard claims: exp (expiry — reject expired), iss (issuer), aud (audience — the token was minted for you), and nbf/iat as appropriate.
  • Keep the payload non-secret. A JWT is signed, not encrypted — anyone can base64-decode and read it. Never put passwords, secrets, or sensitive personal data in the claims.
  • Protect the signing key like any other secret, and keep access-token lifetimes short because, as established, you cannot revoke a JWT before it expires.

Where Tokens Live In The Browser

For browser clients, prefer an HttpOnly cookie over localStorage for anything long-lived. localStorage is readable by any script, so an XSS flaw becomes token theft; an HttpOnly cookie is not script-readable. If you must hold an access token in the page (common for SPAs calling APIs), keep it in memory only and rely on a refresh flow, so there is no persistent store for an attacker to loot. Cookies bring CSRF into scope, which SameSite plus CSRF tokens handle — the trade is deliberate, not accidental.

What To Check

Before moving on, make sure you can:

  • state the session/JWT trade-off and default to sessions for server-rendered apps
  • set HttpOnly, Secure, and SameSite and say what each defends
  • regenerate session IDs on login and explain session fixation
  • verify JWT signatures with a pinned algorithm and validate exp/iss/aud
  • explain why a JWT payload is readable and must hold no secrets
  • choose browser storage (HttpOnly cookie or memory) over localStorage

What You Should Be Able To Do

After this lesson, you should be able to implement secure PHP sessions with correct cookie flags and fixation defense, implement JWT validation that resists the algorithm-confusion attacks, choose between sessions and tokens on the merits rather than fashion, and store tokens in the browser without creating an XSS theft primitive.

Practice

Practice: Choose And Implement A Session Mechanism

For two applications — a server-rendered PHP monolith and a stateless JSON API with a mobile client — choose sessions or JWTs for each, justify the choice, and specify the concrete security settings each implementation needs.

Show solution

API with mobile client: JWT access token + revocable refresh token. Statelessness genuinely helps (no session lookup per API call, easy horizontal scale), and the mobile client is not a browser. Implementation: access JWT ~15 min, signed with a pinned algorithm, verified on every request with exp/iss/aud checks; refresh token opaque, stored hashed server-side, rotated on use with reuse-detection (per the access/refresh lesson). For the web frontend of the same API, access token in memory and refresh token in an HttpOnly cookie; for the mobile app, refresh token in platform secure storage.

The two choices illustrate the rule: sessions by default for server-rendered apps, JWTs where statelessness pays and paired with a revocable refresh token to restore control.

Practice: Diagnose Token Implementation Bugs

Task

Identify every security bug and its consequence.

Show solution
  • Trusting the token's alg header. Accepting whatever algorithm the token claims enables the classic attacks: alg: none (attacker strips the signature entirely and the verifier accepts an unsigned token) and algorithm confusion (an RS256 verifier tricked into treating the public key as an HMAC secret). Either yields forged tokens with any claims the attacker wants — total auth bypass. Fix: pin the expected algorithm at verification, never read it from the token.
  • "Verifying" by decoding without checking the signature. If the code base64-decodes and reads exp but does not cryptographically verify the signature, the token is unauthenticated input. Combined with the point above, the JWT provides no security at all. Fix: use a library that verifies the signature with the pinned key.
  • Role in the payload, trusted for authorization. Even with signing fixed, this is fragile: the moment signature verification is weak (as here), the attacker sets role: admin. And a JWT payload is readable by anyone, so any sensitive claim is disclosed. Authorization should be re-derived server-side or at minimum depend on a properly verified token; sensitive data never goes in claims.
  • localStorage storage. Readable by any script, so an XSS flaw becomes token theft. Fix: HttpOnly cookie, or memory-only with a refresh flow.
  • Admin session cookie with no flags. No HttpOnly (XSS can steal the admin session), no Secure (sent over plain HTTP), no SameSite (CSRF against admin actions). For the highest-privilege surface in the app, this is the worst place to omit them. Fix: all three flags plus session regeneration on login.

The unifying failure is trusting unverified input: the token's algorithm, the token's claims, and the storage all assume good faith where the whole job is to assume none.

Practice: Define Token Implementation Checks
Show solution
  • A test inspects session cookies and asserts HttpOnly, Secure, and SameSite are set — on the admin surface especially.
  • A test asserts the session ID changes across login (session_regenerate_id), guarding against fixation regressions.
  • JWT tests feed the verifier an alg: none token, a token signed with the wrong key, and one with a swapped algorithm, asserting each is rejected — these encode the algorithm-confusion defense directly and are the most important JWT tests to have.
  • Tests assert exp, iss, and aud are all validated, and that an expired or wrong-audience token fails.
  • A test/grep guard asserts no secrets or sensitive personal data appear in JWT claims.
  • A check confirms browser clients use HttpOnly cookies or in-memory storage, never localStorage, for long-lived tokens.

Human, periodically:

  • Confirm the session-versus-JWT choice still fits each surface — a monolith that grew a JWT login for no reason, or an API still doing per-request session lookups at scale, are both worth revisiting.
  • Verify signing keys are stored as secrets and rotated, and that access-token lifetimes remain short given JWTs' non-revocability.
  • Confirm session storage is shared (Redis/DB) wherever more than one server runs, so revocation and lookup are consistent across the fleet.