JWT Authentication: How It Works, When It Fits, and Common Pitfalls
A practical look at JWT authentication — token structure, signing mechanics, and the trade-offs you need to understand before reaching for it.
JWT has become the default authentication mechanism in modern web applications. That ubiquity is largely a good thing — but “default” doesn’t mean “always the right choice.” In this post I’ll explain how JWT works, when it earns its keep, and the mistakes I’ve seen repeatedly over the years.
What JWT is and what problem it solves
JWT (JSON Web Token) is a token format for conveying verifiable information between two parties. It consists of three sections separated by dots:
header.payload.signature
header declares the token type and the signing algorithm; payload carries the actual data; signature is the first two sections signed with a secret key. The signature lets a server verify that the token content hasn’t been tampered with — without sharing the key with anyone.
The core problem JWT solves is this: the server doesn’t need to keep session state on its end. The token validates itself. This eliminates the headache of sharing session state across multiple servers.
What you pay for it
That statelessness isn’t free. With a stateful session you can revoke a user instantly — delete the record and it’s done. With JWT, a token remains valid until it expires, no matter what you do. Revoking a compromised token is inherently hard; it’s baked into how JWT works.
That’s why I frame JWT this way: excellent for short-lived access; weak for scenarios where you need “I must be able to invalidate this session at any moment.”
You can patch this weakness with a blacklist — store revoked tokens somewhere and check every incoming request against that store. But be honest with yourself: at that point you’ve abandoned the original promise of JWT, which was “the server holds no session state.” The moment you add a blacklist, you give back some of the simplicity that statelessness offered. That’s not necessarily a bad decision — but it should be a conscious one.
In practice: signing and verification
To make this concrete, here’s a small class that does signing and verification by hand. Don’t write this yourself in production — use a battle-tested package like firebase/php-jwt. This is just to make the mechanism visible:
class JWT
{
protected const ALGORITHM = 'HS256';
protected const SIGNATURE_ALGORITHM = 'sha256';
public static function encode(string $key, array $payload, ?int $ttl = null): string
{
$header = base64_encode(json_encode([
'typ' => 'JWT',
'alg' => self::ALGORITHM,
]));
$payload = base64_encode(json_encode([
'data' => $payload,
'created_at' => time(),
'ttl' => $ttl,
]));
return $header . '.' . $payload . '.' . self::signature($key, $header, $payload);
}
public static function decode(string $key, string $jwt): array|false
{
try {
[$h, $p, $signature] = explode('.', $jwt);
if (!hash_equals(self::signature($key, $h, $p), $signature)) {
return false;
}
$payload = json_decode(base64_decode($p), true);
if ($payload['ttl'] !== null && time() - $payload['created_at'] > $payload['ttl']) {
return false;
}
return $payload['data'];
} catch (\Throwable) {
return false;
}
}
private static function signature(string $key, string $header, string $payload): string
{
return base64_encode(hash_hmac(self::SIGNATURE_ALGORITHM, $header . '.' . $payload, $key, true));
}
}
One line here that shouldn’t go unnoticed: I compare signatures with hash_equals(), not ==. A plain equality check is vulnerable to timing attacks — an attacker can infer information from how long the comparison takes. It’s a small detail, but in security code there’s no such thing as a “small detail.”
Mistakes I’ve seen over the years
- Long-lived access tokens. Access tokens should expire in 15 minutes to an hour. For persistent sessions, use a separate, longer-lived refresh token.
- Storing tokens in the wrong place. Putting a token in
localStorageexposes it to XSS. AnHttpOnly+Securecookie is significantly safer. - Putting sensitive data in the payload. The
payloadis signed but not encrypted — anyone who base64-decodes it can read it. Passwords, ID numbers, and similar sensitive values have no place there. - Forgetting the
alg: noneattack. If the verification side blindly reads the algorithm from the token itself, an attacker can bypass the signature entirely. Hard-code the expected algorithm in your code; never trust the token to tell you how to verify it.
Summary
JWT is an elegant mechanism — but it isn’t magic. You pay for the scalability benefit of statelessness with the difficulty of revocation. If you keep tokens short-lived, store them in the right place, and verify signatures rigorously, the trade-off is reasonable for most applications. If you can’t meet those conditions, a classic server-side session is still a perfectly respectable choice — newer doesn’t always mean better.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.