NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
JWT for Laravel

Minting user tokens

User tokens are RS256 JWTs signed on the issuer app with the RSA private key. The issuer pins iss, aud, sub, iat, nbf, exp, jti and scope — these always overwrite a caller-supplied claim of the same name, so extra claims can never spoof identity.

Access tokens

AccessTokenRequest is an immutable, fluent builder — every method returns a new instance. The resulting token has scope = access:

use RoundlyConsulting\Jwt\Facades\Jwt;
use RoundlyConsulting\Jwt\UserTokens\AccessTokenRequest;

$request = AccessTokenRequest::for($user->id)                     // subject (string|int)
    ->email($user->email, verified: $user->hasVerifiedEmail())    // email + email_verified claims
    ->tokenVersion($user->token_version)                          // tv claim (freshness)
    ->permissions('posts.view', 'posts.edit')                     // permissions claim
    ->withClaims(['org' => 42]);                                  // arbitrary extra claims

$issued = Jwt::mintAccessToken($request);
  • for($subject) — the sub claim; accepts a string or an int.
  • email($email, verified: bool) — the email and email_verified claims.
  • tokenVersion(int) — the tv claim compared by the guard’s token-version check.
  • permissions(...$abilities) — the permissions claim read by claim-based authorization.
  • withClaims(array) — arbitrary extra claims; registered and known claims always win.

Returning the token

Every mint returns a readonly IssuedToken:

$issued->token;      // string — the compact JWS to return to the client
$issued->expiresAt;  // CarbonImmutable — the expiry
$issued->jti;        // string — the token id (store it to denylist later)

return response()->json([
    'access_token' => $issued->token,
    'token_type' => 'Bearer',
    'expires_at' => $issued->expiresAt->toIso8601String(),
]);

Audience, TTL and session claims

audience() and ttl() steer the issuer and are never written into the payload. sid, amr and auth_time are emitted only when you set them, so existing call sites mint exactly what they always did:

AccessTokenRequest::for($user->id)
    ->audience('app-clients')                 // aud — defaults to jwt.audience
    ->ttl(600)                                // seconds — defaults to jwt.ttl
    ->sessionId($familyId)                    // sid
    ->authMethods('pwd', 'otp', 'mfa')        // amr (RFC 8176)
    ->authTime($loggedInAt);                  // auth_time — int or any DateTimeInterface

$claims->sessionId();    // ?string
$claims->authMethods();  // list<string>, [] when absent
$claims->authTime();     // ?int

ttl() below one second, an empty sessionId() and an empty or missing authMethods() throw InvalidArgumentException. amr is de-duplicated, first occurrence wins.

Challenge, email-verify and custom scopes

The 2fa_pending and email_verify mints use the configured challenge_ttl and verify_ttl — no hand-passing the number:

Jwt::mintChallengeToken($subject);                 // scope=2fa_pending, exp = challenge_ttl
Jwt::mintEmailVerifyToken($subject, $user->email); // scope=email_verify, exp = verify_ttl

// Extra claims on a challenge token:
Jwt::mintChallengeToken($subject, ['method' => 'totp']);

// Any other scope via the generic mint():
Jwt::mint($subject, 'my_custom_scope', ttl: 120, extraClaims: ['org' => 42]);

The Scope enum

The four built-in scopes are a backed enum. mint() accepts a Scope case or any string, so custom scopes stay free strings:

use RoundlyConsulting\Jwt\UserTokens\Scope;

Scope::Access->value;        // 'access'
Scope::TwoFaPending->value;  // '2fa_pending'
Scope::EmailVerify->value;   // 'email_verify'
Scope::Service->value;       // 'service'

Jwt::mint($subject, Scope::Access, ttl: 900);  // pass an enum case, or any string
Scope::values();                               // Collection: ['access', '2fa_pending', …]

Without the facade

Prefer the facade. Under the hood it delegates to the UserTokenIssuer contract (bound to NativeUserTokenIssuer), which you can also resolve directly — see DI and actions:

use RoundlyConsulting\Jwt\UserTokens\AccessTokenRequest;
use RoundlyConsulting\Jwt\UserTokens\Contracts\UserTokenIssuer;

$issued = app(UserTokenIssuer::class)->mintAccessToken(
    AccessTokenRequest::for($user->id)->email($user->email, verified: true)
);

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.