These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

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):

SettingValueNotes
AlgorithmHS256 (default)config/jwt.php:134
Token TTL20160 minutes (14 days)config/jwt.php:104
Refresh TTL20160 minutes (14 days)config/jwt.php:123 — also overridable via JWT_REFRESH_TTL env
Required standard claimsiss, iat, exp, nbf, sub, jticonfig/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):

FieldTypeSource
tokenstring | nullJWT from /login or /register
userIdnumber | nulltoAuthPayload().id
userEmailstring | nulltoAuthPayload().email
userNamestring | nulltoAuthPayload().name
userRolestring | nulltoAuthPayload().role
subscriptionStatusstring | nulltoAuthPayload().subscription_status
subscriptionTierstring | nulltoAuthPayload().subscription_tier
trialEndsAtstring | nullISO 8601 datetime
paddle_*variousPaddle billing state
permissionsstring[]e.g. ['admin.access', 'users.manage'] for admins
isAuthenticatedbooleanset to true by setUser, false by logout
hasHydratedbooleanset 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 fetchAPI call 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 to defaultUser (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:

  1. 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']); });
  1. Nested admin group inside the main auth:api group (routes/api.php:140-188) — user management and the full /admin/* console (AI logs, audit logs, user CRUD, workspace/location/finder/set/tag CRUD via AdminConsoleController):
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_status must be 'paid'; 'unpaid' users are denied regardless of tier.
  • Tier ranking: none (0) < bronze (1) < premium (2). A user with premium satisfies subscription_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):

RouteAuthPurpose
GET /ext/finders/details/{key}noneFetch finder payload (CDN-bypassed origin fallback)
DELETE /ext/finders/details/{key}auth:apiInvalidate cached payload (builder use)
GET /ext/finders/details/{key}/infoauth:apiFetch cache info (builder use)
POST /ext/finders/details/{key}/syncauth:apiRebuild + 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.