Development

PHP Sessions vs JWT: Which One Your App Actually Needs

This argument has a predictable shape. Somebody suggests sessions, somebody else says sessions do not scale, the word stateless appears, and within ten minutes the team has agreed to put JWTs into an application that is one PHP server talking to one MySQL database and serving four hundred users.

Six months later they are writing a token blocklist, because a staff member left and nobody could log them out.

This is not an argument against tokens. Tokens are the right answer for several real situations. It is an argument against choosing them for the reason everybody gives, because that reason is almost always wrong, and the real difference between the two approaches is something else entirely.

A session cookie points at server state. A JWT carries the state with it.
A session cookie points at server state. A JWT carries the state with it.

What a session actually is

A PHP session is two things. On the server there is a store — a file in /var/lib/php/sessions by default, or a Redis key, or a row in a database table. In the browser there is a cookie holding nothing but a long random identifier.

When a request arrives, PHP reads the cookie, looks up the store, and hands you $_SESSION. That is the whole mechanism. The cookie is a coat check ticket. The ticket is meaningless on its own; everything valuable is behind the counter.

<?php
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => true,     // HTTPS only
    'httponly' => true,     // JavaScript cannot read it
    'samesite' => 'Lax',    // not sent on cross-site POSTs
]);
session_start();

// after checking the password
session_regenerate_id(true);       // new id, old one destroyed
$_SESSION['user_id']   = $user['id'];
$_SESSION['logged_at'] = time();

That session_regenerate_id(true) line is not optional. Without it, an attacker who can set a victim’s session id before they log in still holds a valid id afterwards. This is session fixation, and it is one line of code away from being impossible. We have written about the wider problem in session hijacking in PHP.

What a JWT actually is

A JSON Web Token is three base64url strings joined by full stops: a header, a payload, and a signature. The payload is plain JSON that anybody can read — it is encoded, not encrypted. The signature proves that your server produced it and that nobody has edited a character since.

// the payload, once decoded, is just this
{
  "sub": 42,
  "role": "admin",
  "org": 7,
  "iat": 1789934400,
  "exp": 1789938000
}

So the token is not a ticket. It is the record itself, travelling in the user’s pocket, with your signature on it. Your server does not need to look anything up to trust it — it verifies the signature, checks exp, and believes the rest.

“Signed” means nobody tampered with it. It does not mean the contents are still true. A token that says role: admin stays a valid, correctly signed, admin token even after you have demoted that person in the database. That single sentence is the whole of the debate.

The bad reason everybody gives

The reason you will hear is: JWTs are stateless, so they scale, so they are better. Every clause in that sentence needs examining.

Stateless is a property, not a benefit

Statelessness buys you exactly one thing: a server can validate a request without consulting a shared store. That matters when you have several services that do not share a database, or an API gateway that must reject requests before they reach anything, or a genuinely large fleet of servers.

If your application is one PHP server, or two behind a load balancer, you already have a shared store. It is the MySQL database you are querying on every request anyway. Reading a session row from it costs a sub-millisecond primary key lookup. You are not saving anything.

Sessions scale fine, and the fix is boring

The version of this objection with real substance is: file-based sessions do not work across multiple servers, because server B cannot read server A’s session files. That is true. The solution is not a new authentication architecture. It is one line in a config file.

; php.ini or .user.ini
session.save_handler = redis
session.save_path    = "tcp://127.0.0.1:6379?auth=secret"

; and the flags that matter, set globally
session.cookie_secure   = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1

Laravel is the same — change SESSION_DRIVER from file to redis or database in .env and restart. Sessions now work across any number of servers. That is the entire migration.

The database lookup you were avoiding happens anyway

Here is the part that undoes the argument completely. A JWT saves you the session lookup. But what does your controller do next? It loads the user, because it needs their name, their organisation, their plan, their permissions. That is a database query. The one you were avoiding was going to be answered from the same connection, using the same index.

Unless you have deliberately built an application where most requests need no user record at all, statelessness has saved you nothing measurable and cost you the ability to log anybody out.

Revocation is the one difference you will actually feel, usually on a bad day.
Revocation is the one difference you will actually feel, usually on a bad day.

Revocation is the real difference

Strip away the folklore and one difference remains. With sessions, you hold the state, so you can change it. With tokens, the user holds it, so you cannot.

Think about the things that happen in a real business:

  • A laptop is stolen. With sessions: delete that row, done, within seconds. With tokens: the thief stays logged in until the token expires.
  • Somebody leaves the company. You disable the account in your admin panel. With a token, they are still an admin on the tab they left open.
  • A password is changed after a phishing attempt. Sessions: drop every other session for that user. Tokens: every issued token keeps working.
  • A role is downgraded. Sessions: the next request reflects it. Tokens: the claim inside the token is stale until it is reissued.
  • A user wants to see their active devices and sign one out. Sessions: a list, with a button. Tokens: you cannot show a list of things you never stored.

The standard answer is short token lifetimes. Fifteen minutes, say, so the window is small. That helps, and it is what you should do, but read it honestly: it is a promise that unauthorised access ends within a quarter of an hour, not immediately. For a banking application that is unacceptable. For a blog it is fine. Know which one you are building.

And the moment you add a blocklist, you have a session

The other answer is a revocation list — store the jti of every revoked token and check it on each request. It works. Look at what it is, though: a server-side store, consulted on every request, holding authentication state. You have rebuilt sessions, with a signature and a JSON payload on top, and now you maintain both.

There is a version of this that is honest and reasonable: keep a tokens_valid_after timestamp on the user row, and reject any token issued before it. One column, one comparison, and “log out everywhere” works. But you should adopt it knowing you have chosen stateful tokens, not because somebody told you tokens were stateless.

Where each one genuinely belongs

Once revocation is understood, the choice stops being ideological and becomes a question about the shape of your application.

Most small teams are in the first two rows, and always have been.
Most small teams are in the first two rows, and always have been.

A server-rendered PHP site: sessions

Blade templates, forms that POST, redirects. Sessions are already there, already secure by default, already integrated with CSRF protection and flash messages. There is no argument for anything else. Putting a JWT into a server-rendered site means storing it somewhere the browser can hand back on every navigation — which means a cookie — which means you have a session with extra steps.

An SPA on your own domain: still sessions

This is the case people get wrong most often. If your React or Vue front end is served from app.example.com and the API is api.example.com, those are the same site. A cookie set on .example.com is sent automatically with every request. Laravel Sanctum is built exactly for this and uses cookie sessions, not tokens, for first-party SPAs.

You get HttpOnly storage, instant revocation, and no token refresh logic in the front end at all. The one thing you must handle is CSRF, which has a complete and well-understood fix — see our note on preventing CSRF in PHP forms.

A mobile or desktop app: tokens

Now the argument flips. A native app has no cookie jar you want to rely on, needs to stay logged in for weeks rather than hours, and is installed on one device that you can bind the credential to. A long-lived opaque token stored in the platform keychain is the right shape.

Note the word opaque. For your own app talking to your own API, a random 64-character string in a database table is simpler than a JWT and gives you revocation for free. Laravel Sanctum’s API tokens are exactly this. You only need a JWT when something must verify the token without asking you.

A public or partner API: tokens, scoped

Machines calling your API are not browsers. They need a credential they can put in an Authorization header, it must be scoped so a reporting integration cannot delete records, and it must be revocable per integration without disturbing anybody else. Tokens, with scopes, stored server-side.

Several services behind one login: this is what JWTs are for

If you have four services in different languages and none of them share a database, a signed token that each can verify independently is genuinely the right tool. This is the problem JWT was designed for. Most small teams do not have this problem, and adopt the solution anyway.

Storage, which is where the real security lives

Whatever you choose, the credential has to sit somewhere in the browser, and that decision matters more than the choice of mechanism.

Cookies trade an XSS problem for a CSRF problem. Only one of those has a complete fix.
Cookies trade an XSS problem for a CSRF problem. Only one of those has a complete fix.

The four cookie flags

  • Secure — the cookie is never sent over plain HTTP. Without it, one request to http:// leaks the credential on any shared network.
  • HttpOnly — JavaScript cannot read it. This is the flag that means an XSS bug steals actions rather than the login itself.
  • SameSiteLax for nearly everything: the cookie is sent on top-level navigation but not on cross-site POSTs, which removes most CSRF. Use Strict for an admin panel and accept that links from email will land on a login page. None requires Secure and should make you ask why.
  • Path and Domain — keep them narrow. A cookie on .example.com is readable by every subdomain, including the WordPress install somebody set up on blog.example.com.

localStorage is a choice, and it has consequences

Every JWT tutorial stores the token in localStorage, usually without discussing it. Here is the consequence, stated plainly: any JavaScript running on your page can read localStorage. Your code, a dependency you did not audit, an analytics tag your marketing team added, or an injected script from an XSS bug.

A stolen HttpOnly cookie cannot be read by an XSS payload, so the attacker is limited to acting inside that browser session. A stolen token from localStorage is exfiltrated to a server and replayed from anywhere, for as long as it is valid.

“We will just not have XSS” is not a plan. You have a dependency tree. The realistic position is to assume a script may one day run where it should not, and to make sure it cannot walk away with the login.

If you must use tokens in a browser, keep the access token in a JavaScript variable in memory — never persisted — and the refresh token in an HttpOnly cookie. A page reload calls refresh once to get a new access token. XSS can use the credential while the tab is open; it cannot steal something it can persist.

Refresh tokens, briefly and honestly

The refresh pattern exists to make short token lifetimes survivable. Two credentials: a short-lived access token, fifteen minutes, sent with every request; and a long-lived refresh token, days or weeks, sent only to one endpoint, whose only job is to issue a new access token.

This gives you a revocation window of fifteen minutes while the user stays logged in for a fortnight. The refresh token lives in the database, so revoking it is instant. Notice what that means: the part you can actually revoke is the stateful part.

<?php
// POST /auth/refresh  — the only endpoint that accepts a refresh token
$row = $db->prepare('SELECT * FROM refresh_tokens WHERE token_hash = ?');
$row->execute([hash('sha256', $incoming)]);
$t = $row->fetch();

if (!$t || $t['revoked_at'] || strtotime($t['expires_at']) < time()) {
    http_response_code(401);
    exit;
}

// rotation: this refresh token can never be used again
$db->prepare('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?')
   ->execute([$t['id']]);

$new = bin2hex(random_bytes(32));
$db->prepare('INSERT INTO refresh_tokens (user_id, token_hash, family_id, expires_at)
              VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))')
   ->execute([$t['user_id'], hash('sha256', $new), $t['family_id']]);

Two details in there are the whole reason to do it properly. Store a hash of the refresh token, not the token — if the table leaks, it contains nothing usable. And rotate on every use. If a token that has already been rotated is presented again, that is a replay: revoke the entire family and force a fresh login. It is the only signal you get that a refresh token was stolen.

Things that go wrong with tokens in practice

  • The algorithm confusion attack. Some libraries used to accept alg: none, or let an attacker sign with the public key as an HMAC secret. Always pass the expected algorithm explicitly when decoding. Never let the token choose.
  • A weak secret. An HS256 secret that is a dictionary word can be brute-forced offline from any token you have issued. Use 32 random bytes and keep it out of the repository.
  • Putting things in the payload that should not be public. It is base64, not encryption. Email addresses, phone numbers and internal notes in a JWT are readable by anybody holding it.
  • Clock skew. Two servers a minute apart will reject each other’s freshly issued tokens. Allow a small leeway and keep NTP running.
  • Forgetting to check exp. Some libraries verify the signature and nothing else unless you ask. Verify the signature, the expiry, the issuer and the audience.

A straight recommendation for a small team

If you are a team of two to twenty people, building a web application with a PHP backend, this is what to do.

  1. Use sessions for anything a browser talks to. Server-rendered pages and your own SPA both. Use the framework’s implementation, not your own.
  2. Move the session driver to Redis or the database the day you add a second server. It is a config change, not a project.
  3. Set the four cookie flags — Secure, HttpOnly, SameSite=Lax, a narrow Path — and regenerate the id on login and on privilege change.
  4. Use opaque database-backed tokens for your mobile app and for any API a customer integrates with. Hash them at rest, scope them, and show the user a list they can revoke.
  5. Reach for a JWT only when something must verify a token without asking you — a separate service, a partner, a gateway. Then keep the lifetime short and the payload boring.
  6. Whatever you choose, write down how you log one person out immediately, and test it. If you cannot answer that in one sentence, the design is not finished.

There is no prize for the more sophisticated architecture. The application that a stranger can read and that lets you kill a compromised login in four seconds is the better one, and for most small teams that application uses sessions.

On Monday morning

Open your authentication code and answer three questions. First: if someone’s laptop is stolen at 11am, what do you run, and how long until they are out? If the answer involves waiting for an expiry, decide now whether that is acceptable and write down why.

Second: grep for localStorage and for setcookie. Look at where the credential lives and which flags are set. Adding httponly and samesite to an existing cookie is a five-minute change with no visible effect on users.

Third: find the place you construct a session or a token after a successful password check, and confirm that the identifier is regenerated there. If it is not, fix that one before lunch. It is the cheapest security improvement in the file.