lcobucci/jwt
A simple, RFC 7519-compliant PHP library for creating, signing, and verifying JSON Web Tokens and JSON Web Signatures, with support for multiple signing algorithms and a clean, well-documented API.
The package is a standalone JWT implementation — it does not ship Laravel service providers, facades, or a guard. You wire it into Laravel yourself. That is the whole integration surface, and it is small.
composer require lcobucci/jwt:^5.6
Requires PHP 8.1+ (5.x dropped PHP 7 support). You will also want lcobucci/clock (pulled in transitively) for testable time handling.
Look here first: Lcobucci\JWT\Configuration. Every 5.x workflow starts from one of three factories — forSymmetricSigner(), forAsymmetricSigner(), forUnsecuredSigner() (tests only). It bundles the parser, signer, keys, and validator so you never pass keys around by hand.
Issue a token:
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InsecureKey;
$config = Configuration::forSymmetricSigner(
new Sha256(),
InsecureKey::plainText(config('jwt.secret')) // InsecureKey = "I know this is a shared secret"
);
$now = new DateTimeImmutable();
$token = $config->builder()
->issuedBy(config('app.url'))
->permittedFor('mobile-app')
->identifiedBy((string) Str::uuid())
->issuedAt($now)
->expiresAt($now->modify('+15 minutes'))
->withClaim('uid', $user->id)
->getToken($config->signer(), $config->signingKey());
return $token->toString();
Parse and assert:
use Lcobucci\JWT\Token\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Lcobucci\Clock\SystemClock;
$token = $config->parser()->parse($jwt);
assert($token instanceof UnencryptedToken); // gives you claims()
try {
$config->validator()->assert(
$token,
new Constraint\SignedWith($config->signer(), $config->verificationKey()),
new Constraint\IssuedBy(config('app.url')),
new Constraint\PermittedFor('mobile-app'),
new Constraint\StrictValidAt(SystemClock::fromUTC()),
);
} catch (RequiredConstraintsViolated $e) {
// $e->violations() => array of ConstraintViolation
abort(401);
}
$uid = $token->claims()->get('uid');
First use case: replace an ad-hoc signed string or a session lookup for a stateless API. Issue short-lived access tokens for your SPA/mobile client, validate in middleware, keep uid/role as claims.
Bind Configuration as a singleton. Register it in a service provider and inject it — rebuilding it per request re-reads and re-parses key files. Choose the factory from config:
$this->app->singleton(Configuration::class, fn () => config('jwt.algorithm') === 'RS256'
? Configuration::forAsymmetricSigner(
new Signer\Rsa\Sha256(),
InMemory::file(config('jwt.private_key_path')),
InMemory::file(config('jwt.public_key_path')),
)
: Configuration::forSymmetricSigner(new Signer\Hmac\Sha256(), InsecureKey::plainText(config('jwt.secret')))
);
A TokenIssuer service, not inline builder calls. Inject Configuration plus a TTL config; expose issue(User $user, array $extraClaims = []). Controllers and jobs depend on this, never on Builder directly.
A TokenValidator service + middleware. One class owns parse → assert → return a small DTO (userId, jti, claims). Middleware catches the three exception families (decode, structure, constraint) and maps them all to a single opaque 401 — do not leak why validation failed.
Compose constraints rather than writing checks. SignedWith, IssuedBy, PermittedFor, IdentifiedBy, RelatedTo, HasClaim, StrictValidAt/LooseValidAt — pass them as variadic args to assert(). Build your own by implementing Lcobucci\JWT\Validation\Constraint (one method: assert(Token $token): void), e.g. a RequiresScope('invoices:read') constraint. This is the cleanest extension point the package offers.
Asymmetric keys for anything crossing a trust boundary. RS256/ES256/EdDSA so resource servers verify with a public key only. Load PEMs from a path outside the repo or a secrets manager; never commit private_key_path contents into config/.
jti + cache for revocation. On logout/rotation, Cache::put("revoked:{$jti}", true, $token->claims()->get('exp') - now()). Add a TokenNotRevoked constraint so revocation is enforced by the same assert() call as everything else.
Test with forUnsecuredSigner() and a frozen clock. lcobucci/clock's FrozenClock lets you assert expiry/nbf behaviour deterministically:
$config = Configuration::forUnsecuredSigner();
$clock = new FrozenClock(new DateTimeImmutable('2025-01-01 00:00:00'));
Remember forUnsecuredSigner() produces genuinely unsigned tokens — guard against it leaking into a production binding (e.g. an if (app()->isProduction()) throw in the provider).
Refresh tokens over long-lived access tokens. Keep access TTLs in minutes, pair with a revocable refresh token stored server-side (DB or Redis) referenced by jti.
Version-pin deliberately. "lcobucci/jwt": "^5.6.1" — 5.6.1 is a hardening patch (see Gotchas); anything below it leaves you with a 500-on-bad-input path when a hostile or buggy client sends a malformed token.
The 3.x/4.x API is gone. Builder::sign(), Parser, ValidationData, $token->validate(), $token->verify() all no longer exist. If you are porting an older integration: signing happens inside getToken($signer, $key), verification is the SignedWith constraint, and standard-claim checking is StrictValidAt/LooseValidAt. Most stale tutorials you will find online are written against 3.x.
parser()->parse() returns Token, not UnencryptedToken. claims() only exists on UnencryptedToken, so assert($token instanceof UnencryptedToken) (or an explicit instanceof check in production code) before touching claims.
Parse-time errors vs. validation errors are different exception families. InvalidTokenStructure, CannotDecodeContent, and UnsupportedHeaderFound are thrown by the parser; RequiredConstraintsViolated by the validator. Catch both — a single catch (Throwable) around the whole block is fine only if it maps to 401 and logs, never re-throws as 500.
5.6.1 changes exactly two of those paths — review your error handling. Non-numeric date claims (exp: "soon") now throw InvalidTokenStructure at parse time instead of failing later or surfacing an odd exception, and malformed ECDSA/EdDSA signatures (wrong-length, garbage bytes) now fail verification cleanly instead of throwing an unhandled low-level error. Both are correctness/DoS fixes: if your monitoring shows 500s from token endpoints, those were attacker- or bug-triggered, and they become clean 401s on 5.6.1. Practical consequence: make sure InvalidTokenStructure and RequiredConstraintsViolated are both mapped, and that you never treat "parse threw" as "server error".
Do validation in UTC, always. SystemClock::fromUTC() (or SystemClock::fromSystemTimezone() if you really know what you are doing). Claims are numeric timestamps; passing Carbon::now() with a non-UTC timezone into issuedAt()/expiresAt() shifts every window. Never build timestamps with strtotime()/mktime() and no timezone.
Clock skew is real — use leeway, not longer TTLs. new LooseValidAt($clock, new DateInterval('PT30S')) in place of StrictValidAt absorbs a few seconds of drift between issuers; StrictValidAt is zero-tolerance and will reject perfectly good tokens from a slightly fast client clock.
claims()->get() returns mixed and throws when the claim is absent. Do not assume uid exists because you issued it — tokens from older deploys, or an Unsecured/re-signed token, may not carry it. Check $token->claims()->has('uid') or wrap, and cast explicitly ((int)) so a string claim cannot smuggle into a typed constructor.
Symmetric keys: InsecureKey, not InMemory. The InsecureKey type exists to make shared-secret usage obvious in review. For asymmetric keys load PEM contents via a Key implementation reading from a path — and pass a Key object, never a raw string, so a truncated/misread file fails loudly instead of producing a subtly wrong signature.
JWTs are stateless; "logout" is a server-side concept. Without a jti denylist (or short TTLs + refresh token rotation) a stolen token stays valid until exp. Also: revocation lookups must be TTL'd to exp, or your cache grows forever.
Never log the raw token or key material. Log jti, sub/uid, iss, and the constraint violation — not the compact string. Add authorization to whatever request-logging scrubber you use; Laravel's default does not redact it.
Rate-limit issuance and validation endpoints. Signing is CPU work (especially RSA/ECDSA) and an unauthenticated token endpoint is a free oracle. RateLimiter + throttle middleware on issue/refresh routes.
How can I help you explore Laravel packages today?