
Most PHP APIs do not need a JWT for a browser login.
If a user signs in, your server sets a session cookie, and every later request hits the same PHP process that can read that session, a normal server-side session is the simpler design. Logout is a row delete. A stolen cookie dies when you delete the session. You do not invent refresh tokens to paper over a session you already had.
A JWT is the right tool when the service that receives the credential cannot ask your session store. That is an internal service verifying a token from your identity service, or a public API that accepts tokens from an IdP. The receiver checks the signature and a small set of claims and then does the work.
The usual mistake is treating firebase/php-jwt or lcobucci/jwt as the modern replacement for $_SESSION. The library signs bytes. It does not choose the algorithm for you, it does not invent an audience, and it does not reject an empty environment secret. Those are your checks.
Ask one question before you add a JWT package: does another service need to verify this credential without your session store? If no, keep the session cookie. If yes, verify strictly. Pin the algorithm, check issuer and audience, require expiry, and prove the happy path is not the only path that returns 200.
This page is the 2026 PHP half of that decision. The Express sister page is JWT in Express. What follows is the verifier, the empty-secret case, and the five tokens that must return 401.
firebase/php-jwt 7.1.0 landed on Packagist on 11 June 2026. The 5.x call JWT::decode($jwt, $key, array('HS256')) died in 6.0.0, and copy-paste tutorials still hardcode the secret secure_coding and stamp iat from 2013. If that secret leaks, every access token still inside exp stays valid.
The Express sibling already made the same call. Read JWT in Express: skip it for cookie sessions before you add a PHP library. This page is the verifier for the cases that remain. Keep the session overview next to the cookie path, and the secure coding checklist next to the claims.
A cookie session is the default
A JSON Web Token is a signed claims set. RFC 7519 names the claims. RFC 8725 is the BCP for how to treat them. The format does not grant a session, a logout, or an authorization decision. It moves a blob you later have to believe.
A leaked PHPSESSID is one row you can delete. A leaked HMAC secret or private key validates every access token still inside exp. Logout on a JWT is a denylist you promised you would not need, or a five-minute lifetime that still leaves a window. If one PHP process owns the browser session, keep a random id in a __Host- cookie, put the CSRF check on that cookie, and stop.
Reach for a token when one of these is true:
- Service-to-service. Billing has to accept a token the identity app minted, without sharing a session table.
- Multi-audience. One issuer, several APIs, and each API must refuse a token that was not minted for it.
- A third-party API. You are the resource server. The client already speaks Bearer.
An internal job that already shares the database does not qualify. A mobile client talking to one origin still does not qualify if a cookie works. When you do mint a token, the rest of this page is the verifier.
| Cookie session | Token you actually need | |
|---|---|---|
| What the browser holds | Opaque id in __Host-session | Signed claims in Bearer or __Host-access |
| Who can revoke today | Delete the row | Wait for exp, or look up a jti (that is a session store) |
| Logout everywhere | One DELETE | Only if you kept that store |
| When it earns its keep | One PHP app, one cookie | Another service must verify without your session table |
SecureCoding
Pick a current encoder
As of 25 August 2026, both Packagist pages. Two libraries are current, and they are not interchangeable.
The Firebase package at 7.1.0 is the small encode and decode API. 7.0.0 on 15 December 2025 added key-size validation and rejects a secret below the minimum. Stay on that 7.1 line or newer. The README binds each key to one algorithm through Firebase\JWT\Key.
lcobucci/jwt 5.6.0 published on 17 October 2025. 6.0 is still 6.0.x-dev. 5.0.0 removed Signer\None, Configuration::forUnsecuredSigner(), and empty keys. The validation API is the product: SignedWith, StrictValidAt, and PermittedFor.
Do not install a 5.x firebase/php-jwt line. Do not copy a 2018 blog that passes a string key and a separate algorithm list into decode. Composer should pin ^7.1 for the Firebase package or ^5.6 for lcobucci, not a floating *.
Bind the algorithm. Refuse none
Algorithm confusion is CWE-347 adjacent: the token names an algorithm, and the verifier believes the header. The 2021 firebase/php-jwt issue, CVE-2021-46743, was a key-ring plus mixed algorithms before 6.0.0. The fix was structural. JWT::decode now takes a Key that already names HS256, RS256, or EdDSA. A token that claims a different algorithm does not verify against that key.
firebase/php-jwt does not implement alg: none on decode. Do not write a helper that skips the signature when the header says none. lcobucci 5 dropped the unsecured signer on purpose. The upgrade notes say if you still want it, you must implement it yourself. Do not.
function hmacSecret(): string
{
$secret = getenv('JWT_HS256_SECRET');
if (!$secret || strlen($secret) < 32) {
throw new RuntimeException('missing JWT_HS256_SECRET');
}
return $secret;
}
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use UnexpectedValueException;
function decodeAccessToken(string $jwt): object
{
$decoded = JWT::decode($jwt, new Key(hmacSecret(), 'HS256'));
if (!audienceAllowed($decoded)) {
throw new UnexpectedValueException('audience rejected');
}
return $decoded;
}
hmacSecret and decodeAccessToken are the named helpers. The algorithm is a literal on the Key, not a string from the token. The secret comes from the environment. A tutorial value, including secure_coding, is a failed review.
// BAD: dead 5.x signature. Do not copy.
// JWT::decode($token, $secret, ['HS256']);
For RS256, bind the public key to RS256 only. Do not put an HMAC secret and an RSA public key in the same key ring unless each kid maps to one Key with one algorithm. The README multi-key example is that map.
Check aud after the signature
JWT::decode verifies the signature and the time claims. It does not require aud. A token minted for a different API will verify if it shares the secret. That is the cross-service replay RFC 8725 warns about. Compare the claim after decode, and refuse the request when the claim is missing.
function audienceAllowed(object $decoded): bool
{
$expected = getenv('JWT_AUD');
if (!$expected) {
return false;
}
$aud = $decoded->aud ?? null;
if (is_string($aud)) {
return hash_equals($expected, $aud);
}
if (is_array($aud)) {
foreach ($aud as $item) {
if (is_string($item) && hash_equals($expected, $item)) {
return true;
}
}
}
return false;
}
audienceAllowed is the named check decodeAccessToken already called. An empty JWT_AUD returns false. Do not treat a missing env as “any audience.” jose 6.2.5 on the Express page had to fix a falsy-audience skip. PHP can grow the same bug in three lines.
On lcobucci 5.6.0 the constraint is PermittedFor. Pair it with SignedWith and StrictValidAt. LooseValidAt skips a missing exp. Do not use it on an access token. An empty JWT_AUD must throw before the first request, not fall back to a sample host.
use Lcobucci\Clock\SystemClock;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint as Rule;
function lcobucciConfig(): Configuration
{
$aud = getenv('JWT_AUD');
if (!$aud) {
throw new RuntimeException('missing JWT_AUD');
}
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText(hmacSecret())
);
$config->setValidationConstraints(
new Rule\SignedWith($config->signer(), $config->signingKey()),
new Rule\StrictValidAt(SystemClock::fromUTC()),
new Rule\PermittedFor($aud)
);
return $config;
}
lcobucciConfig is the factory. Call parser()->parse, then validator()->assert with $config->validationConstraints(). A missing constraint is a fail-open. Put all three in the set before the first request.
exp, nbf, and a small leeway
firebase/php-jwt throws ExpiredException when exp is in the past, and BeforeValidException when nbf or iat is in the future. That is already closed on time, if those claims exist. Mint exp on every access token. A token without exp is a permanent credential the first time the secret leaks.
The README sets JWT::$leeway = 60 for clock skew. Keep it at 60 seconds or less. A ten-minute leeway is an expired token you still accept. Do not use leeway to paper over a missing NTP setup.
use Firebase\JWT\JWT;
use UnexpectedValueException;
function encodeAccessToken(string $sub): string
{
$now = time();
$payload = [
'iss' => getenv('JWT_ISS'),
'aud' => getenv('JWT_AUD'),
'sub' => $sub,
'iat' => $now,
'nbf' => $now,
'exp' => $now + 300,
];
return JWT::encode($payload, hmacSecret(), 'HS256');
}
function handleBearer(string $jwt): object
{
JWT::$leeway = 60;
try {
return decodeAccessToken($jwt);
} catch (UnexpectedValueException $e) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'unauthorized']);
exit;
}
}
encodeAccessToken mints five minutes. handleBearer is the request path. ExpiredException extends UnexpectedValueException, so one catch covers a bad signature, a bad time claim, and a rejected audience. Map every miss to HTTP 401 with a generic body. Do not echo $e->getMessage() to the client. The message names whether the signature, the time, or the audience failed, which is more than a caller needs.
Do not put the token in localStorage if the page can run a script. A cookie with HttpOnly, Secure, SameSite=Lax, and the __Host- prefix is storage, not a JWT feature. Mixing a bearer header with a session cookie is two logout stories.
Prove the verifier with your own tokens
You are not walking an exploit. You are proving decodeAccessToken returned 401 on tokens you minted wrong on purpose.
- Mint a token with
encodeAccessTokenand a 1-secondexp. Sleep 2 seconds. Replay it on your own route. Expect 401. - Mint a token whose
audishttps://other.example. Replay it. Expect 401. - Call
decodeAccessTokenwith an empty string. Expect an exception, not a guest user. - Unset
JWT_AUDin a unit test.audienceAllowedmust return false.
curl -sS -D - -o /dev/null \
-H "Authorization: Bearer PASTE_EXPIRED_TOKEN_YOU_MINTED" \
"https://your-app.example/v1/me"
# Expect: HTTP/2 401
Grep the dead signature and the tutorial secret:
rg -n "JWT::decode\\([^,]+,\\s*\\$|secure_coding|alg.?none|forUnsecuredSigner|Signer\\\\None" \
--glob '!vendor'
A hit on the three-argument decode, on a literal secret, or on an unsecured signer is a review. Then confirm Composer locked the Firebase package at 7.1.0 or newer, or lcobucci at 5.6.0 or newer.
If a partner rotates keys, use kid plus a map of Key objects, or CachedKeySet against a JWKS URL you control. The 7.1.0 README says CachedKeySet refreshes when it sees an unknown kid and can rate-limit lookups. Do not decode the header first to pick an algorithm. The kid selects a key that already has an algorithm bound.
Questions we keep getting
Does JWT::decode check aud for me?
No. Do not treat a verified signature as a verified audience. 7.1.0 checks the signature and the time claims. You compare aud after decode, and you refuse the request if JWT_AUD is empty or the claim is missing.
Is alg none still a live PHP bug?
Not in these two libraries, if you stay on firebase/php-jwt 7.1 or lcobucci 5.6 and never add a shim. The live miss is a tutorial that still passes a raw key and a caller-chosen algorithm list into decode.
When is a JWT the right PHP tool?
Not for one PHP app and a browser cookie. That case is a session row. Use a token when a second service must verify the caller without opening your session store.



