Zero-Dependency JWT Decoding in PHP
You can decode a JWT payload in vanilla PHP without any Composer package. PHP's Base64 functions do not handle URL-safe encoding natively, so you need a small helper.
<?php
function base64UrlDecode(string $data): string {
$padding = 3 - ((3 + strlen($data)) % 4);
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', $padding));
}
function decodeJwtPayload(string $token): ?array {
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
return json_decode(base64UrlDecode($parts[1]), true);
}
$claims = decodeJwtPayload($_SERVER['HTTP_AUTHORIZATION'] ?? '');
echo $claims['sub']; // user ID
echo date('c', $claims['exp']); // expiration in ISO 8601This works in PHP 7.4+ with zero dependencies. Use it for logging, debugging, and inspection only — the signature is not verified.
Using firebase/php-jwt — The Standard Library
The most widely-used PHP JWT library. Simple static API, framework-agnostic, actively maintained.
Installation
composer require firebase/php-jwtVerify with HS256 Secret
<?php
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
$secret = getenv('JWT_SECRET');
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = str_replace('Bearer ', '', $token);
try {
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
// $decoded is stdClass with sub, exp, iss, aud, etc.
$userId = $decoded->sub;
$email = $decoded->email ?? null;
$roles = $decoded->roles ?? [];
} catch (ExpiredException $e) {
http_response_code(401);
exit(json_encode(['error' => 'Token expired']));
} catch (SignatureInvalidException $e) {
http_response_code(401);
exit(json_encode(['error' => 'Invalid signature']));
} catch (Exception $e) {
http_response_code(401);
exit(json_encode(['error' => 'Invalid token']));
}Verify with RS256 Public Key
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$publicKey = file_get_contents('/path/to/public.pem');
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
// Access claims
echo $decoded->sub;
echo $decoded->iss;Verify with JWKS (Multiple Keys)
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
// In production, CACHE this fetch (30 min TTL) via Redis or filesystem
$jwksJson = file_get_contents('https://auth.example.com/.well-known/jwks.json');
$jwks = json_decode($jwksJson, true);
$keys = JWK::parseKeySet($jwks);
// firebase/php-jwt auto-matches the kid header to the correct key
$decoded = JWT::decode($token, $keys);Laravel Integration — Custom Middleware
For a lightweight Laravel setup without pulling in the full tymon/jwt-auth package, write a small middleware.
1. Create the middleware
<?php
// app/Http/Middleware/VerifyJwt.php
namespace App\Http\Middleware;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\Request;
class VerifyJwt {
public function handle(Request $request, Closure $next) {
$token = $request->bearerToken();
if (!$token) {
return response()->json(['error' => 'Missing token'], 401);
}
try {
$decoded = JWT::decode($token, new Key(config('jwt.secret'), 'HS256'));
$request->attributes->set('jwt', $decoded);
$request->attributes->set('user_id', $decoded->sub);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid token'], 401);
}
return $next($request);
}
}2. Register in Laravel 11+ (bootstrap/app.php)
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'jwt.verify' => \App\Http\Middleware\VerifyJwt::class,
]);
})
->create();3. Apply to routes
// routes/api.php
Route::middleware('jwt.verify')->group(function () {
Route::get('/me', function (Request $request) {
$jwt = $request->attributes->get('jwt');
return response()->json([
'userId' => $jwt->sub,
'email' => $jwt->email ?? null,
'roles' => $jwt->roles ?? [],
]);
});
});Using tymon/jwt-auth for Full Laravel JWT Setup
For projects wanting full JWT-based authentication (login, refresh, logout, guards):
composer require tymon/jwt-auth
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
php artisan jwt:secret
// config/auth.php
'guards' => [
'api' => ['driver' => 'jwt', 'provider' => 'users'],
],
// Login controller
public function login(Request $request) {
$token = auth('api')->attempt($request->only('email', 'password'));
if (!$token) return response()->json(['error' => 'Unauthorized'], 401);
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
// Protected route
Route::middleware('auth:api')->get('/me', function () {
return auth('api')->user();
});Common PHP JWT Errors
- Firebase\JWT\ExpiredException — exp claim in the past. Client should refresh.
- Firebase\JWT\SignatureInvalidException — signature does not match. Check that your secret/public key matches the signer.
- Firebase\JWT\BeforeValidException — nbf claim is in the future. Rare unless clocks are badly skewed.
- UnexpectedValueException: Wrong number of segments — token was truncated or malformed. Ensure client sends the full three-segment token.
- UnexpectedValueException: Algorithm not allowed — the alg in the token header does not match what you passed to Key(). Always specify explicit algorithm; never trust the token's alg header alone (algorithm confusion attack).
- DomainException: OpenSSL unable to verify — the RS256 public key is malformed. Check the PEM includes BEGIN/END markers and no extra whitespace.
Security Notes for PHP JWT Handling
- Store the JWT_SECRET in
.env— never commit to git. Rotate quarterly. - HS256 secrets must be at least 32 random bytes. Generate with
openssl rand -base64 32. - Always pin the algorithm — passing "HS256" as the Key algo prevents an attacker from forcing "none" or "RS256".
- Cache JWKS responses with a 30-minute TTL to survive identity-provider rate limits without hammering it on every request.
- Use HTTP-only, Secure, SameSite=Strict cookies for refresh tokens — never expose them to JavaScript.
- Do not decode tokens without validation in production code — decoded-but-unverified claims are attacker-controlled.
Key Facts
- Standard lib:
- firebase/php-jwt 6.10+ (composer require firebase/php-jwt)
- Laravel lib:
- tymon/jwt-auth for full JWT auth guard; or custom middleware with php-jwt
- HS256 secret:
- 32+ random bytes. Generate: openssl rand -base64 32
- RS256 key:
- Load PEM with file_get_contents. Pass to Key($pem, "RS256").
- JWKS:
- JWK::parseKeySet($jwksArray) — auto-matches kid
Related JWT Tools
- JWT Decoder Online — decode any token instantly
- JWT Decoder Node.js — jsonwebtoken and jose patterns
- JWT Decoder Python — PyJWT for Flask/Django/FastAPI
- JWT Decoder Java — jjwt and Spring Boot patterns
- Verify JWT Signature — HS256/RS256 verification