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(); // ?intttl() 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 cryptoBy 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.