Backend — auth and roles
JWT issuance, role gating, and subscription-tier gating — all middleware-driven.
For the higher-level JWT flow (including frontend token storage and the 401 redirect path), see Architecture — Auth.
JWT issuance (tymon/jwt-auth)
Package: tymon/jwt-auth ^2.1, registered via config/jwt.php.
Algorithm: HS256 (default via JWT_ALGO; override with env var) — config/jwt.php:134
Token lifetime: ttl = 20160 minutes (14 days) — config/jwt.php:104
Refresh TTL: JWT_REFRESH_TTL (default 20160 min, 14 days) — config/jwt.php:123
Blacklist: enabled by default (JWT_BLACKLIST_ENABLED=true) — config/jwt.php:220. Tokens are added to the blacklist on POST /logout. Grace period defaults to 0 seconds (JWT_BLACKLIST_GRACE_PERIOD) — config/jwt.php:235.
Secret: JWT_SECRET (HMAC symmetric) — config/jwt.php:28. Generate with php artisan jwt:secret.
The api guard
config/auth.php:43-46 registers an api guard with driver: jwt and provider: users. Every protected route uses the auth:api middleware, which resolves the JWT and hydrates $request->user() with the authenticated User Eloquent model.
JWTSubject implementation
app/Models/User.php:12 — User implements JWTSubject:
| Method | Returns |
|---|---|
getJWTIdentifier() | $this->getKey() (primary key) — User.php:42 |
getJWTCustomClaims() | ['role', 'subscription_status', 'subscription_tier'] — User.php:46-53 |
The three custom claims are embedded in every issued token, so middleware can read them without a database round-trip. However, the middleware implementations shown below intentionally re-read the live model values from $request->user() (which is already database-loaded by the guard) to avoid acting on stale JWT claims after an account change.
Token issuance flow
POST /api/login—AuthController::loginvalidates credentials, callsauth()->attempt()which invokestymon/jwt-authand returns a signed bearer token.- Token is returned in the response body alongside
toAuthPayload()(user fields + permissions array —User.php:210-231). - Every subsequent request carries
Authorization: Bearer <token>. Theauth:apimiddleware validates, refreshes if needed, and sets$request->user().
Password hashing
password column is cast to hashed in User::casts() — User.php:109. Laravel’s default bcrypt driver applies APP_BCRYPT_ROUNDS (defaults to 12 in .env.example:15).
Role system
Constants
Defined on User — app/Models/User.php:17-18:
| Constant | Value | Meaning |
|---|---|---|
ROLE_ADMIN | 'admin' | Full admin console access; bypasses all subscription-tier checks |
ROLE_USER | 'user' | Standard customer account; default on registration |
Default at creation: ROLE_USER — User.php:35.
EnsureUserRole middleware
app/Http/Middleware/EnsureUserRole.php — alias role, registered in bootstrap/app.php:25.
// EnsureUserRole.php:11-19
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user || !in_array($user->role, $roles, true)) {
return response()->json(['message' => 'You do not have permission to access this resource.'], 403);
}
return $next($request);
}Usage in routes: 'role:admin'. Multiple roles are passed as additional variadic arguments (e.g. role:admin,moderator), though only admin and user currently exist in the codebase.
Admin route group
routes/api.php:156-204 — all /users CRUD and every /admin/* route are wrapped in middleware('role:admin'). The /admin/* prefix covers:
- AI log management (
GET/POST/DELETE /admin/ai-logs) - Audit log browsing (
GET /admin/audit-logs,GET /admin/audit-logs/{auditLog}) - User, workspace, location, finder, set, and tag management under
/admin/
Admin invite sending (POST /invite/send) uses its own standalone middleware group at routes/api.php:53.
Checking role in application code
User::isAdmin() — User.php:163 — returns $this->role === self::ROLE_ADMIN.
User::permissions() — User.php:199 — returns ['admin.access', 'users.manage'] for admins, empty array for others. The permissions array is included in toAuthPayload() and sent to the frontend after login.
Subscription-tier system
Constants
Defined on User — app/Models/User.php:23-27:
| Constant | Value | Rank |
|---|---|---|
SUBSCRIPTION_TIER_NONE | 'none' | 0 |
SUBSCRIPTION_TIER_FREE | 'free' | 0 |
SUBSCRIPTION_TIER_BRONZE | 'bronze' | 1 |
SUBSCRIPTION_TIER_PREMIUM | 'premium' | 2 |
SUBSCRIPTION_TIER_CUSTOM | 'custom' | 3 (bypasses rank check) |
Subscription status constants — User.php:20-21:
| Constant | Value |
|---|---|
SUBSCRIPTION_STATUS_UNPAID | 'unpaid' |
SUBSCRIPTION_STATUS_PAID | 'paid' |
Default at creation: subscription_status = 'unpaid', subscription_tier = 'free' — User.php:36-37.
EnsureSubscriptionTier middleware
app/Http/Middleware/EnsureSubscriptionTier.php — alias subscription_tier, registered in bootstrap/app.php:26.
// EnsureSubscriptionTier.php:11-19
public function handle(Request $request, Closure $next, string ...$tiers): Response
{
$user = $request->user();
if (!$user || !$user->canAccessSubscriptionTiers($tiers)) {
return response()->json(['message' => 'This feature requires a qualifying subscription.'], 403);
}
return $next($request);
}Usage in routes: 'subscription_tier:free', 'subscription_tier:bronze', etc. Multiple tiers are passed as variadic args; the middleware checks whether the user’s tier rank is at least as high as the highest-ranked tier in the list.
canAccessSubscriptionTiers() logic
User.php:177-196 — the authoritative tier-check method:
- Admin bypass — if
isAdmin()returns true, access is granted unconditionally. - Custom tier bypass —
subscription_tier === 'custom'always passes. - Unpaid check — if not
hasPaidSubscription()and tier is not'free', access is denied. - Rank comparison — fetches the current rank from
subscriptionTierRanks()and the required rank (max of all passed tier ranks); passes ifcurrentRank >= requiredRank.
Tier usage in routes
The workspace CRUD group is the only route group currently gated by subscription_tier:free — routes/api.php:147-153. This grants access to all users at the free tier or above (which in practice means all authenticated users, since free rank is 0).
⚠️ Warning: The
subscription_tier:freegate is rank-0, so it is effectively a paid-status gate for tiers above free. Any new feature requiring a paid subscription should usesubscription_tier:bronze(rank 1) orsubscription_tier:premium(rank 2).
Middleware registration
bootstrap/app.php:23-28 — alias table:
| Alias | Class |
|---|---|
auth (built-in) | Laravel’s Authenticate |
audit | App\Http\Middleware\RecordAuditLog |
role | App\Http\Middleware\EnsureUserRole |
subscription_tier | App\Http\Middleware\EnsureSubscriptionTier |
auth_session | App\Http\Middleware\TrackAuthSession |
Middleware composition order
Every protected route group uses: auth:api → auth_session → audit — routes/api.php:34. Then, within nested route groups, role:admin or subscription_tier:<tier> is appended as an inner middleware.
Full stack for an admin route: auth:api → auth_session → audit → role:admin.
For the audit middleware detail, see Backend — audit logging. For the audit header protocol sent by the frontend, see Next.js → Backend integration.