werk365/jwtauthroles
A Laravel package that combines JSON Web Token authentication with role-based access control, letting you issue JWTs and restrict routes or actions by user role. A lightweight option for adding token auth and simple role checks to existing Laravel apps.
The package plugs FusionAuth-issued JWTs into Laravel's auth system and exposes the roles claim to your app. It supports two verification modes: a static PEM public key, or a JWKS endpoint (the production-friendly option, since FusionAuth rotates keys).
Version 1.3.1 explicitly declares support for Laravel 9 and 10. If you're on Laravel 11 or 12, expect to fork and widen the constraint before you can install at all.
Minimal path:
composer require werk365/jwtauthroles:^1.3.1
php artisan vendor:publish --provider="Werk365\JwtAuthRoles\JwtAuthRolesServiceProvider"
php artisan migrate # if the package ships role/user tables
Configure .env:
# Pick one: JWKS (recommended) or a PEM public key
JWT_JWKS_URL=https://auth.example.com/.well-known/jwks.json
# JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
JWT_ALGORITHM=RS256
JWT_ISSUER=https://auth.example.com
JWT_AUDIENCE=my-laravel-api
Wire the guard in config/auth.php:
'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
Add the package trait to your User model (check the installed trait name — it's the one that gives you hasRole/hasAnyRole and the JWT sub mapping):
use Werk365\JwtAuthRoles\Traits\JwtAuthRoles;
class User extends Authenticatable
{
use JwtAuthRoles;
}
Where to look first:
composer.json — confirm the laravel/framework and illuminate/* constraints say ^9.0|^10.0 before you build on it.config/jwtauthroles.php — claim names (sub, roles), key source, algorithm, user model binding.role: middleware alias exists.sub or email, and whether it persists roles.First real use case: a SPA or mobile app authenticates against FusionAuth, receives an access token, and calls your Laravel API with Authorization: Bearer <token>. Your API validates the signature (via JWKS), resolves the sub to a local user, syncs roles, and gates routes on those roles.
Route::middleware('auth:api')->get('/me', fn (Request $r) => $r->user());
Route::middleware(['auth:api', 'role:admin'])->group(function () {
Route::get('/admin/users', [UserController::class, 'index']);
});
Guard-per-surface. Keep the JWT guard distinct from any Sanctum/session guard (auth:jwt vs auth:web). Mixing them makes Auth::user() unpredictable and complicates middleware ordering.
Map sub explicitly. Don't assume FusionAuth's sub equals users.id. Add a fusionauth_id (or sub) column, make it unique and indexed, and configure the package to resolve on it. Email-based matching breaks the first time a user changes their email in FusionAuth.
Decide where roles live. Two viable models:
roles from the decoded token on each request. Zero DB writes, always fresh, but not queryable ("list all admins") and doesn't survive token expiry.roles/role_user tables at authentication time. Queryable and joins well, but you must use sync() semantics so revoked FusionAuth roles actually disappear, and you should sync on login or token refresh — not on every request.Authorization gates over ad-hoc checks. Wrap role logic once:
Gate::define('manage-billing', fn (User $u) => $u->hasAnyRole(['billing', 'admin']));
Then use $this->authorize('manage-billing') in controllers or @can in Blade. This keeps role semantics out of route files and makes them testable.
Middleware for coarse gates, policies for fine ones. Use role:admin on route groups for obvious boundaries; use policies when the decision depends on the resource (e.g., "editor of this team").
JWKS with caching. In production, wrap the JWKS fetch in Cache::remember() with a TTL (an hour is typical) and handle the kid-not-found case by busting the cache once and retrying. Without caching, every API request makes an outbound HTTP call to FusionAuth.
PEM for local/dev. A static public key is simpler in tests and local environments; switch to JWKS in staging/prod so key rotation doesn't require a deploy.
Testing. Mint tokens with a throwaway keypair in tests/, point the package config at the test public key in phpunit.xml, and assert on sub/roles claims. For unit tests of controllers, actingAs($user, 'api') is usually enough if the guard honors it.
Token refresh is FusionAuth's job, not Laravel's. Keep access tokens short-lived, let the client refresh, and never issue your own tokens from this package.
Fork-first on Laravel 11+. 1.3.1 tops out at Laravel 10. Rather than hacking composer.json in vendor/, use a VCS/path repository fork, bump the illuminate/* constraints, and run the test suite — the guard surface is small enough that this is usually a constraint edit, not a code change.
^1.3.1, read the guard source before shipping, and be prepared to fork. The 1.3.1 constraint bump means Laravel 9/10 are officially covered — anything older or newer is on you.composer require will refuse to resolve. Verify with composer why-not werk365/jwtauthroles 1.3.1 (it'll name laravel/framework), then fork and widen rather than forcing with --ignore-platform-reqs (that flag won't help — it's a version constraint, not a platform requirement).config/jwtauthroles.php and does not trust the token header's alg. A guard that accepts none or an HS256 token signed with the public key as a secret is a critical vulnerability. Verify this in the source.kid misses. After rotation, a cached JWKS may not contain the token's kid. Retry once with a fresh fetch; don't hard-fail the request on the first miss.exp/nbf if the package exposes it; otherwise keep hosts NTP-synced..env. Multi-line PEM is painful. Either use escaped \n, base64-encode the key and decode in config/jwtauthroles.php, or reference a mounted file path.roles or a namespaced custom claim like https://example.com/roles. If hasRole() always returns false, decode a real token (jwt.io or base64 -d) and compare to roles_claim in config. This is the single most common silent failure.iss/aud validation. Confirm the package actually validates them. If it doesn't, add it — a valid signature from the wrong issuer is still a valid signature.auth:api is already Sanctum/Passport in your app, name the JWT guard something else (auth:fusion) and update route middleware accordingly.role: middleware isn't auto-registered, add it in bootstrap/app.php (Laravel 11+) or Kernel.php (10 and below). The 401/403 responses should be JSON for an API — check your exception rendering for AuthenticationException.with('roles')) before calling hasRole() in a loop or collection map.sub and jti only; full bearer tokens in logs are a credential leak.How can I help you explore Laravel packages today?