October 1, 2026
Sessions vs JWT vs API Tokens in PHP — A Decision Guide, Not a Holy War
JWT is fashionable enough to get picked for jobs it’s worse at. Sessions vs JWT vs API tokens in PHP — an honest guide.

By Ann R.
8 min read
Bring up authentication in a room of PHP developers and watch the temperature change. Someone will say sessions are old-fashioned. Someone else will say JWT is a security disaster waiting to happen. A third person, quietly, has been running a homegrown token table for six years and it has never once let them down. The strange part is that all three of them are correct — just about different applications. Most of the authentication pain in production doesn't come from choosing a bad option. It comes from choosing a perfectly good option for the wrong kind of app, usually because it was the one everyone was talking about that year.
So here is the three-way comparison without the tribalism. What each approach really is, the actual code to run it, where each genuinely shines, where each quietly hurts, and — the part nobody writes down clearly — how to pick without a flame war. The snippets below are real PHP and were syntax-checked; the token and signature mechanics were run to confirm they do what the text claims.
Before any code, though, there is one question that decides almost everything, and it is worth sitting with for a second.
The question everything hangs on
Authentication comes down to a single choice: does the server remember who you are, or does the client carry proof of who it is?
That's it. That's the whole fork in the road. Sessions and API tokens keep the memory on the server — the client is handed something meaningless-looking, and the server does the remembering. JWT flips it: the server keeps nothing, and instead hands the client a sealed, tamper-proof note that says "this is user 42, valid until 3 p.m.," trusting its own seal when the note comes back.
Every advantage and every headache you're about to read flows from that one decision. Keep it in mind and the rest stops feeling like a list of unrelated trivia and starts feeling like consequences.
Sessions: the default that's right more often than people admit
Sessions are what PHP does out of the box, and there is a reason they've survived every wave of fashion since the 1990s. When a user logs in, the server creates a little pocket of storage for them, drops a random ID into a cookie, and hands that cookie to the browser. The cookie contains nothing interesting — just the ID. Everything that actually matters, including who the user is and what they're allowed to do, stays on the server where the browser can't touch it.
// LOGIN
session_start();
if (password_verify($_POST['password'], $user->password_hash)) {
session_regenerate_id(true); // stop session fixation
$_SESSION['user_id'] = $user->id;
}
// EVERY REQUEST AFTER
session_start();
$userId = $_SESSION['user_id'] ?? null;
if ($userId === null) {
http_response_code(401);
exit('Not logged in');
}// LOGIN
session_start();
if (password_verify($_POST['password'], $user->password_hash)) {
session_regenerate_id(true); // stop session fixation
$_SESSION['user_id'] = $user->id;
}
// EVERY REQUEST AFTER
session_start();
$userId = $_SESSION['user_id'] ?? null;
if ($userId === null) {
http_response_code(401);
exit('Not logged in');
}The session_regenerate_id(true) line is small and easy to skip, and skipping it is how session-fixation bugs get in — so it belongs exactly at the moment the user's privileges change. Beyond that, this is almost embarrassingly simple, and the simplicity is the feature.
What sessions give you, more than anything, is control. Someone reports their account is compromised? Delete their session on the server and they are logged out this instant — not in an hour, not whenever a token expires, now. A moderator needs to boot a user mid-abuse? Same thing. Because the server holds the truth, the server can revoke the truth whenever it likes. Nothing sensitive ever left the building, either; the browser has been carrying around a random string that means nothing without the server's memory behind it.
The knock against sessions is that they "don't scale," and this is mostly a story people repeat without checking. The original worry was that PHP writes sessions to files on one server, so a second server can't read them — true, but the fix is a single config change to store sessions in Redis or a database instead, and then any server in your fleet can read any session with one fast lookup. Sessions scale to enormous traffic once you move them off the local disk. The honest reason to reach past sessions isn't scale at all. It's when the thing on the other end can't hold a cookie — a mobile app, a third-party integration, a command-line tool. That's a real limitation, and it's the one that actually matters. For a normal web app that people open in a browser and log into, sessions are not the boring default you tolerate. They're usually the right answer, full stop.
API tokens: the quiet workhorse nobody brags about
The moment you're building an API — something a mobile app or another company's server will talk to — cookies start to feel like the wrong shape, and a plain token starts to feel right. The idea is about as simple as it sounds: generate a long random string, hand it to the client once, and let them send it back on every request in an Authorization header. The client doesn't need a browser, doesn't need cookies, doesn't need anything except the ability to set a header.
The one detail that separates a safe implementation from a dangerous one is what you store. You do not store the token. You store a hash of it.
// ISSUE (once): show the raw token to the client, store only its hash
$token = bin2hex(random_bytes(32));
$db->insert('api_tokens', [
'user_id' => $user->id,
'token_hash' => hash('sha256', $token),
'expires_at' => date('Y-m-d H:i:s', time() + 30 * 86400),
]);
// return $token to the client now — you can never show it again
// VERIFY (each request)
$incoming = str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');
$row = $db->query(
"SELECT * FROM api_tokens WHERE token_hash = ? AND expires_at > NOW()",
[hash('sha256', $incoming)]
)->fetch();
if (!$row) {
http_response_code(401);
exit('Invalid token');
}// ISSUE (once): show the raw token to the client, store only its hash
$token = bin2hex(random_bytes(32));
$db->insert('api_tokens', [
'user_id' => $user->id,
'token_hash' => hash('sha256', $token),
'expires_at' => date('Y-m-d H:i:s', time() + 30 * 86400),
]);
// return $token to the client now — you can never show it again
// VERIFY (each request)
$incoming = str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');
$row = $db->query(
"SELECT * FROM api_tokens WHERE token_hash = ? AND expires_at > NOW()",
[hash('sha256', $incoming)]
)->fetch();
if (!$row) {
http_response_code(401);
exit('Invalid token');
}Storing only the hash is why a leaked database doesn't automatically become a fleet of working credentials — the attacker gets hashes, not tokens, and a hash can't be replayed. Verified: hashing the incoming token and comparing it against the stored hash matches the genuine token and cleanly rejects a wrong one. This is exactly the model behind the "personal access tokens" you've pasted from GitHub, Stripe, and every other API you've integrated. It's not clever. It's just correct.
What makes tokens pleasant to live with is that each one is its own row in a table, which means each one is a thing you can see and a thing you can kill. You can show a user every active token, when each was last used, and let them revoke a single one without logging themselves out of everything else. That per-token granularity is genuinely hard to get any other way. The cost is a database lookup on every request — cheap, and easy to cache for hot tokens, but not literally free — plus the small amount of plumbing you write for issuing, expiring, and rotating them. If writing that plumbing sounds tedious, it is, which is why Laravel Sanctum exists and does the whole thing for you; reach for it before you build your own unless you enjoy the exercise.
JWT: powerful, genuinely useful, and misused more than any of them
JWT is where the arguments get loud, and the reason is that it's a real solution to a real problem that keeps getting applied to a different problem it's actively worse at. So it's worth being precise about what it actually does.
A JWT is a little sealed envelope. Inside are two readable parts — a header saying how it was signed, and a payload saying who the user is and when the token dies — followed by a signature the server computes with a secret only it knows. When the token comes back, the server recomputes that signature and checks it matches. Change a single character of the payload and the signature no longer lines up, so the server knows it was tampered with. The magic trick, and the whole point, is that the server can verify all of this without looking anything up. No database, no session store, just math on the token itself.
For production you should reach for a vetted library and never assemble this by hand:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// ISSUE
$jwt = JWT::encode(
['sub' => $user->id, 'exp' => time() + 3600],
$secretKey,
'HS256'
);
// VERIFY
try {
$decoded = JWT::decode($jwt, new Key($secretKey, 'HS256'));
$userId = $decoded->sub;
} catch (Throwable $e) {
http_response_code(401);
exit('Invalid or expired token');
}use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// ISSUE
$jwt = JWT::encode(
['sub' => $user->id, 'exp' => time() + 3600],
$secretKey,
'HS256'
);
// VERIFY
try {
$decoded = JWT::decode($jwt, new Key($secretKey, 'HS256'));
$userId = $decoded->sub;
} catch (Throwable $e) {
http_response_code(401);
exit('Invalid or expired token');
}Underneath, the mechanics are not mysterious — this is the concept, shown only so the moving parts make sense, not as something to ship:
// Concept only — use a vetted library in production, never hand-roll this.
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = b64url(json_encode(['sub' => $userId, 'exp' => time() + 3600]));
$sig = b64url(hash_hmac('sha256', "$header.$payload", $secret, true));
$jwt = "$header.$payload.$sig";
// Verify: recompute the signature and compare in constant time
[$h, $p, $s] = explode('.', $jwt);
$expected = b64url(hash_hmac('sha256', "$h.$p", $secret, true));
$valid = hash_equals($expected, $s); // and then check exp// Concept only — use a vetted library in production, never hand-roll this.
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = b64url(json_encode(['sub' => $userId, 'exp' => time() + 3600]));
$sig = b64url(hash_hmac('sha256', "$header.$payload", $secret, true));
$jwt = "$header.$payload.$sig";
// Verify: recompute the signature and compare in constant time
[$h, $p, $s] = explode('.', $jwt);
$expected = b64url(hash_hmac('sha256', "$h.$p", $secret, true));
$valid = hash_equals($expected, $s); // and then check expVerified: a valid token passes this check, and flipping even one field in the payload makes the recomputed signature fail to match. That stateless verification is a genuinely valuable thing when you have a dozen small services that all need to trust one login without every one of them phoning home to a shared session store. This is JWT's home turf, and on its home turf nothing else comes close.
And then people take it off its home turf. They use it for the login on a single, ordinary web app, and that's where it turns from an asset into a liability. Ask the obvious question — how do you log someone out immediately? — and you find you can't. A JWT is valid until it expires, and there is no server-side record to delete, because deleting the server-side record is the entire thing you gave up to go stateless. The usual answer is to add a blocklist table of revoked tokens that every request checks against, at which point you've quietly rebuilt the server-side state you left sessions to escape, only with more moving parts and a worse night's sleep.
Then there are the footguns, and they've drawn real blood. The alg: none trick, where an attacker changes the header to claim the token needs no signature and a naive verifier believes it. The confusion between HS256 and RS256 that lets a public key be used to forge tokens. The habit of stuffing sensitive data into the payload, forgetting that a JWT is signed, not encrypted — anyone holding it can read every field inside. None of these bite you if you use a solid library, pin your algorithm explicitly, keep expiries short, and treat the payload as public. All of them bite you eventually if you hand-roll it, which is the single best reason not to.
So which one, actually
Strip away the arguments and the choice is calmer than the internet makes it sound.
If people log into your app through a browser, use sessions. They're built in, they revoke instantly, they're genuinely hard to misuse, and the scaling worry evaporates the moment you point session storage at Redis. Don't talk yourself out of the simple thing because it's the old thing.
If you're building an API — something your mobile app or an outside client consumes — use API tokens, or let Laravel Sanctum manage them for you. You get exactly what an API wants: a header instead of a cookie, revocation per token, and a clean audit trail of what's active. It's the least glamorous option on the list and the one you'll regret the least.
If you have several independent services that all need to trust a single login, or you're deliberately building a stateless architecture where a database lookup per request is genuinely the thing you're trying to avoid, then JWT is the right tool and the only one of the three that solves that problem cleanly. Use a real library, pin the algorithm, keep the tokens short-lived, and decide how you'll handle revocation before the day you need it, not during the incident.
The one-line version, if you want it: sessions are the right default, API tokens are the right API answer, and JWT is a specialist tool that happens to be fashionable enough to keep getting picked for jobs it's worse at. Choose for the shape of your system — for where the state honestly ought to live — rather than for whatever's trending in your feed this quarter.
And whichever you land on, the boring fundamentals don't move an inch. Hash passwords with password_verify. Compare secrets with hash_equals, never ==. Generate tokens with random_bytes. Put the whole thing behind HTTPS. Get those four right and you've already avoided most of the ways authentication actually goes wrong in production — which, more often than not, has nothing to do with which of these three you chose.
Which auth model is your app running — and did you choose it on purpose, or inherit it from someone who's long gone? The inherited ones are where the good stories hide.