Skip to content

Auth Architecture: Guards, WorkOS, Passport, Sanctum

Three names show up constantly in Yaya Engine's auth code — WorkOS, Passport, Sanctum — and it is tempting to assume each maps cleanly to one of the three guards config/auth.php defines. It does not, quite. config/auth.php defines only web, admin, and api [@auth-config]; WorkOS rides the web guard rather than owning a guard of its own, Passport is bound to the admin provider even though it authenticates machine clients rather than admin humans, and Sanctum does not appear in config/auth.php at all because it authenticates through its own middleware layered on top of the web guard's session. Getting this right matters because "which guard is this route behind" is the fastest way to know who — a staff member, an ops manager, a machine client, or an end user — is allowed to hit it.

The three guards and their providers

web    → session driver → users provider       → App\Models\User
admin  → session driver → admin_users provider  → App\Models\AdminUser
api    → passport driver → admin_users provider → App\Models\AdminUser

The api guard's provider is admin_users, not a separate machine-account table — Passport tokens authenticate as AdminUser records, just like the admin session guard does [@auth-config] [@passport-config]. That is why AdminUser carries Laravel\Passport\HasApiTokens (Passport's trait, not Sanctum's) alongside HasRoles from Spatie's permission package and implements both FilamentUser and Passport's OAuthenticatable contract — it is built to be authenticated by three different mechanisms depending on which guard is checking it [@admin-user-model].

WorkOS AuthKit: staff SSO on the plain web guard

routes/auth.php implements login, authentication callback, and logout entirely through WorkOS's own request classes — AuthKitLoginRequest::redirect(), AuthKitAuthenticationRequest::authenticate(), AuthKitLogoutRequest::logout() — wired onto ordinary guest/auth middleware with no guard specified, which means they run against the default web guard [@routes-auth]. routes/settings.php's self-service profile pages layer ValidateSessionWithWorkOS on top of auth, so a web-guard session gets revalidated against WorkOS on every request rather than trusted indefinitely once established [@routes-settings]. The practical effect: WorkOS is Yaya Engine's SSO front door for staff, but architecturally it is not a fourth guard — it is a login flow and a session-validation middleware sitting in front of the same web guard any Laravel app starts with.

Passport: machine tokens for MCP, not end-user login

Passport's job in this codebase is narrow and specific: mint personal-access tokens for service-account AdminUser records so that a machine client — in practice, the internal team using Claude Desktop or Claude Code — can authenticate against the api guard and reach the OAuth-protected /mcp/internal-yaya server (see MCP servers and Dify AI integration). config/passport.php pins the package to the admin guard explicitly ('guard' => 'admin') [@passport-config], which is why Passport tokens resolve to AdminUser, not User — there is no path from Passport to an end user's account. Minting these tokens is itself a controlled admin action: ManageMcpTokens, a Filament page gated to super-admins only, lists and creates tokens scoped to a chosen service-account AdminUser [@mcp-tokens-page]. See Mint MCP Tokens from the Filament Admin Panel for the reasoning behind putting token minting behind a UI rather than a one-off Artisan command.

User uses Sanctum's HasApiTokens trait, and Sanctum's own config lists web as its guard for session-based checks before falling back to bearer-token authentication [@sanctum-config] [@user-model]. In practice, no end user logs in with a password: a Sanctum personal access token is issued only as the last step of one of two flows.

Magic link. MagicLink::generateToken() produces an 8-character random token, retrying up to five times on a database collision before giving up — the magic_links.token column is varchar(64) for historical reasons, but 8 characters is the length actually enforced by this method [@magic-link-model]. MagicLinkService::generate() creates the row with a configurable expiry (auth.magic_link_expiry_hours, defaulting to 72 hours) and a single_use flag that defaults to false unless the caller explicitly passes true [@magic-link-service]. MagicLinkService::verify() checks the token exists and is still valid (MagicLink::isValid() fails closed on expiry, and additionally on reuse only when single_use is set) [@magic-link-model], and exchangeForToken() marks a single-use link used, then calls $user->createToken() to hand back a genuine Sanctum token [@magic-link-service]. Delivery happens over SMS or a WhatsApp template message, both recorded through MessageDeliveryLog for audit purposes. Two real product decisions sit on top of this mechanism: why the token is 8 characters rather than something longer (Decision: Magic Link Tokens Shrink From 64 to 8 Characters), and why single_use defaults to false rather than true (Decision: Magic Link single_use Defaults to False, Not True).

OTP. OtpService generates a numeric code (otp.length, default 6 digits), stores only its hash, expires it after otp.expiry_minutes (default 10), caps verification at otp.max_attempts (default 3), and enforces a otp.cooldown_seconds (default 60) window per phone number before a new code can be requested [@otp-service] [@otp-config]. generate() throws an OtpRateLimitException carrying the remaining cooldown if a caller requests a new code too soon, and verify() returns a structured result with a translatable error_key rather than a raw exception, which is what lets the PWA and Telegram flows surface a localized error message without duplicating error text. If otp.auto_register_enabled is on and a phone number verifies without a matching User, an account can be auto-created against a configured default FSP [@otp-config] — a detail that matters for the verified vs onboarded distinction, since auto-registration alone does not mean either flag has been set.

Both flows converge on the same call: $user->createToken(), Sanctum's own token-issuance method. Everything upstream of that call — WorkOS, Passport, magic links, OTP — is about proving who is asking; Sanctum is the only mechanism that actually hands an end user something to authenticate subsequent PWA requests with.