Auth
JWT issuance and validation across the Next.js app and the Laravel API, plus how the public widget authenticates without a user session.
For the overall system layout see ./overview.md. For how these tokens travel across service boundaries see ../integration-points/index.md. For the backend route groups that enforce these guards see ../codebases/backend/routing.md. For the full endpoint catalog see ../reference/api/index.md.
JWT library and configuration
The backend uses tymon/jwt-auth ^2.1 (composer.json:13). The guard is registered as api and backed by the users Eloquent provider (config/auth.php:43-46):
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],Key config values (config/jwt.php):
| Setting | Value | Notes |
|---|---|---|
| Algorithm | HS256 (default) | config/jwt.php:134 |
| Token TTL | 20160 minutes (14 days) | config/jwt.php:104 |
| Refresh TTL | 20160 minutes (14 days) | config/jwt.php:123 — also overridable via JWT_REFRESH_TTL env |
| Required standard claims | iss, iat, exp, nbf, sub, jti | config/jwt.php:147-154 |
⚠️ Warning: The token TTL is 14 days — long-lived by design. There is currently no short-lived access + refresh token split. A 401 on any fetchAPI call forces a full logout (see Token expiration handling below).
JWT subject — the User model
App\Models\User implements Tymon\JWTAuth\Contracts\JWTSubject (app/Models/User.php:9-11).
JWT identifier — returns the primary key (app/Models/User.php:29-31):
public function getJWTIdentifier()
{
return $this->getKey();
}Custom claims embedded in every token (app/Models/User.php:34-41):
public function getJWTCustomClaims()
{
return [
'role' => $this->role,
'subscription_status' => $this->subscription_status,
'subscription_tier' => $this->subscription_tier,
];
}These claims let middleware gate on role and subscription tier without a database round-trip on every request.
Role constants (app/Models/User.php:16-17):
User::ROLE_ADMIN = 'admin'User::ROLE_USER = 'user'
Subscription tier constants (app/Models/User.php:22-24):
User::SUBSCRIPTION_TIER_NONE = 'none'User::SUBSCRIPTION_TIER_BRONZE = 'bronze'User::SUBSCRIPTION_TIER_PREMIUM = 'premium'
Login flow
Step 1 — POST /login
Route: routes/api.php:26 — unauthenticated, no middleware.
POST /login
Content-Type: application/json
{ "email": "user@example.com", "password": "..." }AuthController::login validates credentials and calls auth('api')->attempt($credentials) (app/Http/Controllers/AuthController.php:69). On failure it returns HTTP 401. On success it calls respondWithToken (app/Http/Controllers/AuthController.php:134-144):
protected function respondWithToken($token)
{
$user = auth('api')->setToken($token)->user();
return response()->json([
'token' => $token,
'token_type' => 'Bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
'user' => $user?->toAuthPayload(),
]);
}expires_in is in seconds (TTL × 60). At 20 160 minutes TTL this resolves to 1 209 600 (14 days).
toAuthPayload() returns the fields serialized to the frontend (app/Models/User.php:175-193): id, name, email, role, subscription_status, subscription_tier, trial_ends_at, paddle_* billing fields, and permissions (an array — populated for admin users only).
Step 2 — GET /me for sync
Route: routes/api.php:34 — protected by auth:api.
GET /me
Authorization: Bearer <token>AuthController::me returns auth('api')->user()?->toAuthPayload() (app/Http/Controllers/AuthController.php:86-88). The frontend calls this on startup to re-hydrate the auth store if a persisted token exists (src/lib/api/auth.ts:32-42).
Registration
Registration (POST /register, routes/api.php:25) is invite-only. The controller validates an Invite record against the submitted token and email before creating or updating the user, then issues a JWT via the same respondWithToken helper (app/Http/Controllers/AuthController.php:19-58). The invite record is deleted on success.
Logout
POST /logout (routes/api.php:33) calls auth('api')->logout(), which blacklists the token’s jti claim (app/Http/Controllers/AuthController.php:78-80).
Frontend token storage — authStore
Token and user state are managed by a Zustand store with persist middleware (src/stores/authStore.ts).
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({ ...defaultUser, hasHydrated: false, setUser, logout, setHasHydrated }),
{
name: 'user', // localStorage key — shared with the Svelte widget app
onRehydrateStorage: () => (state) => { state?.setHasHydrated(true); },
}
)
);The name: 'user' key matches the localStorage key used by the Svelte widget codebase — deliberate; the two apps share a session when running under the same origin.
State shape (src/stores/authStore.ts:7-31):
| Field | Type | Source |
|---|---|---|
token | string | null | JWT from /login or /register |
userId | number | null | toAuthPayload().id |
userEmail | string | null | toAuthPayload().email |
userName | string | null | toAuthPayload().name |
userRole | string | null | toAuthPayload().role |
subscriptionStatus | string | null | toAuthPayload().subscription_status |
subscriptionTier | string | null | toAuthPayload().subscription_tier |
trialEndsAt | string | null | ISO 8601 datetime |
paddle_* | various | Paddle billing state |
permissions | string[] | e.g. ['admin.access', 'users.manage'] for admins |
isAuthenticated | boolean | set to true by setUser, false by logout |
hasHydrated | boolean | set by onRehydrateStorage — gate UI on this before reading auth state |
💡 Tip: Always check hasHydrated before reading isAuthenticated to avoid a flash of unauthenticated UI during hydration. The Next.js app gates route rendering on this flag.
Bearer token attachment — fetchAPI
All authenticated API calls go through fetchAPI (and its internal fetchJson function) in src/lib/api/core.ts.
Token retrieval (src/lib/api/core.ts:20-22):
function getToken(): string | null {
return useAuthStore.getState().token;
}Header attachment (src/lib/api/core.ts:128-133):
const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string> | undefined) };
if (token) {
headers.Authorization = `Bearer ${token}`;
}loginRequest and registerRequest in src/lib/api/auth.ts bypass fetchAPI and call fetch directly — they are unauthenticated by design (src/lib/api/auth.ts:10-29, src/lib/api/auth.ts:79-98).
fetchMe also calls fetch directly and accepts the token as an explicit argument (src/lib/api/auth.ts:32-42) — this lets the caller pass a freshly received token before it is committed to the store.
Token expiration handling
There is no proactive refresh cycle. Token expiry is handled reactively:
- Any
fetchAPIcall that returns HTTP 401 triggers immediate logout and a hard redirect to/login(src/lib/api/core.ts:144-147):
if (response.status === 401 && token && typeof window !== 'undefined') {
useAuthStore.getState().logout();
window.location.replace('/login');
}logout()resets all auth state todefaultUser(all nulls,isAuthenticated: false) and clears the persisted localStorage entry (src/stores/authStore.ts:43).
⚠️ Warning: Because TTL is 14 days and there is no sliding refresh, a token issued close to expiry will not be silently renewed. The user will hit a 401 on their next API call after expiry.
Token blacklisting is enabled by default (config/jwt.php:220: 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true)). The storage provider is Tymon\JWTAuth\Providers\Storage\Illuminate::class (config/jwt.php:297) — Laravel’s cache layer. In production, whether blacklisted JTIs survive a cache flush depends on the configured cache driver (Redis vs. file).
🔴 [NEEDS CLARIFICATION: Confirm the production CACHE_DRIVER value. If it is file or array, a cache flush will un-blacklist logged-out tokens until they naturally expire.]
Admin role gating
The role:admin middleware alias maps to EnsureUserRole (app/Http/Middleware/EnsureUserRole.php):
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);
}The middleware accepts a variadic $roles list, enabling multi-role checks. Currently only role:admin is used in routes.
There are two role:admin groups in the routes file:
- Top-level admin group (
routes/api.php:39-41) — invite sending only:
Route::middleware(['auth:api', 'audit', 'role:admin'])->group(function () {
Route::post('invite/send', [InviteController::class, 'sendInvite']);
});- Nested admin group inside the main
auth:apigroup (routes/api.php:140-188) — user management and the full/admin/*console (AI logs, audit logs, user CRUD, workspace/location/finder/set/tag CRUD viaAdminConsoleController):
Route::middleware('role:admin')->group(function () {
Route::get('/users', [UserController::class, 'index']);
// ...
Route::prefix('/admin')->group(function () { /* full admin console */ });
});Admin users also receive ['admin.access', 'users.manage'] in their permissions array from toAuthPayload() (app/Models/User.php:163-172). The frontend can use these to conditionally render admin UI without making additional API calls.
Subscription tier gating
The subscription_tier:premium middleware alias maps to EnsureSubscriptionTier (app/Http/Middleware/EnsureSubscriptionTier.php):
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);
}Access logic is rank-based, not equality-based (app/Models/User.php:146-161):
public function canAccessSubscriptionTiers(array $tiers): bool
{
if ($this->isAdmin()) {
return true; // admins bypass all tier checks
}
if (!$this->hasPaidSubscription()) {
return false; // unpaid users always denied
}
$ranks = self::subscriptionTierRanks(); // none=0, bronze=1, premium=2
$currentRank = $ranks[$this->subscription_tier] ?? 0;
$requiredRank = max(array_map(fn ($tier) => $ranks[$tier] ?? 0, $tiers));
return $currentRank >= $requiredRank;
}Key points:
- Admins always pass — no subscription required.
subscription_statusmust be'paid';'unpaid'users are denied regardless of tier.- Tier ranking:
none (0) < bronze (1) < premium (2). A user withpremiumsatisfiessubscription_tier:bronze. - Multiple tiers can be listed; the middleware takes the maximum required rank.
Widget auth — finder key only
The public widget (embedded on third-party sites) does not use JWTs. It authenticates via a finder key — a public token embedded in the script URL (e.g. ?key=abc123).
All external/widget routes are served from the ext/ prefix. These routes sit behind auth:api only for internal calls. Public widget fetches go to CDN-cached R2 files (finders/{key}/config.json and the versioned payload), resolved entirely without the Laravel API. No JWT is involved.
The ext/ route group has one public route and one JWT-protected group (routes/api.php:201-208):
| Route | Auth | Purpose |
|---|---|---|
GET /ext/finders/details/{key} | none | Fetch finder payload (CDN-bypassed origin fallback) |
DELETE /ext/finders/details/{key} | auth:api | Invalidate cached payload (builder use) |
GET /ext/finders/details/{key}/info | auth:api | Fetch cache info (builder use) |
POST /ext/finders/details/{key}/sync | auth:api | Rebuild + upload payload to R2 (publish action) |
The JWT-protected ext/ routes are called only by the Next.js app (builder publish flow), not by the public widget.
See ./data-flow-end-user.md for the full widget request trace and ../codebases/backend/routing.md for the complete ext/ route group.
Account self-service
Authenticated users can update their own profile and change their password via PUT /me (routes/api.php:35), handled by AuthController::updateMe (app/Http/Controllers/AuthController.php:90-131). Password and email changes both require current_password verification. The response is the updated toAuthPayload() array; the frontend should call setUser with this data to sync the store.