stacksjs

stacks-auth

Use when implementing authentication, authorization, passkeys, TOTP/2FA, RBAC, gates, policies, session auth, token management, email verification, password resets, or rate limiting in a Stacks application. Covers the @stacksjs/auth package, config/auth.ts, app/Gates.ts, and app/Middleware/.

stacksjs 636 20 Updated 1w ago

Resources

1
GitHub

Install

npx skillscat add stacksjs/stacks/stacks-auth

Install via the SkillsCat registry.

SKILL.md

Stacks Authentication & Authorization

The @stacksjs/auth package provides comprehensive authentication and authorization for Stacks applications, built on @stacksjs/ts-auth.

Key Paths

  • Core package source: storage/framework/core/auth/src/
  • Configuration: config/auth.ts
  • Security config: config/security.ts
  • Hashing config: config/hashing.ts
  • Application gates: app/Gates.ts
  • Application middleware: app/Middleware/
  • Middleware aliases: app/Middleware.ts
  • Auth types: storage/framework/core/types/src/auth.ts

Source Files

auth/src/
├── index.ts              # All re-exports
├── authentication.ts     # Auth class - core auth logic
├── authenticator.ts      # 2FA and personal access client
├── client.ts             # Client re-exports
├── middleware.ts          # Auth middleware handler
├── rate-limiter.ts        # RateLimiter class (5 attempts, 15min lockout)
├── passkey.ts             # WebAuthn/Passkey support
├── password/reset.ts      # Password reset flow
├── register.ts            # User registration
├── user.ts                # Auth user helpers
├── tokens.ts              # Token CRUD, scopes, refresh tokens, OAuth clients
├── gate.ts                # Authorization gates & policies
├── policy.ts              # BasePolicy class + discovery
├── authorizable.ts        # User authorization mixin
├── rbac.ts                # Full RBAC system
├── email-verification.ts  # Email verification flow
└── session-auth.ts        # Session-based SPA auth

Auth Class (authentication.ts) - Static Methods

Login & Authentication

  • Auth.attempt(credentials: AuthCredentials): Promise<boolean> - validate credentials without creating token
  • Auth.validate(credentials: AuthCredentials): Promise<boolean> - checks credentials without setting the request's authenticated user
  • Auth.login(credentials: AuthCredentials, options?: TokenCreateOptions): Promise<{ user, token } | null> - login and create token
  • Auth.loginUsingId(userId: number, options?: TokenCreateOptions): Promise<{ user, token } | null> - login by user ID
  • Auth.logout(): Promise<void> - revoke current token

Personal access tokens (Sanctum-shaped)

oauth_access_tokens is polymorphic: tokenable_type holds the owner's TABLE
name (users, authors) and tokenable_id its id there, so any model
declaring useAuth can hold tokens - not only User.

  • createToken(id, name, scopes, { tokenableType }) - mint one. Returns the
    plaintext ONCE (plainTextToken); the table stores a hash and nothing can
    recover it afterwards. tokenableType defaults to users.
  • tokens(id, tokenableType?) - list an owner's live tokens.
  • tokenCan(scope) / tokenCanAll / tokenCanAny / tokenAbilities - check
    the current request's token.
  • revokeToken, revokeTokenById, revokeAllTokens(id, type?),
    revokeOtherTokens(id, type?) - revocation also revokes the paired refresh
    token, which a raw row delete does not.
  • setTrailActor(id) - attribute writes in a queue job or CLI run that has no
    request to read a user from.

The PersonalAccessToken model maps the same table, so owner.with('tokenable')
lists exactly what createToken minted. It deliberately generates no CRUD
routes: minting and revoking both carry semantics a generic route does not.

  • Auth.once(credentials: AuthCredentials): Promise<boolean> - one-time auth without token
  • Auth.requestUserTokenWithClient(credentials, clientId, clientSecret): Promise<{ token } | null> authenticates both a legacy OAuth client and an end user password, then issues a user token. It is not the client credentials grant.
  • Auth.requestToken(...) is the deprecated compatibility alias for that same legacy exchange. New delegated integrations use the authorization-code provider.

User State

  • Auth.user(): Promise<UserModel | undefined> - resolve the current request user through bearer/token-cookie state
  • Auth.check(): Promise<boolean> - is user authenticated?
  • Auth.guest(): Promise<boolean> - is user a guest?
  • Auth.id(): Promise<number | undefined> - get authenticated user ID
  • Auth.setUser(user: UserModel | undefined): void - set or clear the current request user

Token Creation

  • Auth.createTokenForUser(user, options?: TokenCreateOptions): Promise<NewAccessToken>
  • Auth.createToken(user, name?, abilities?): Promise<AuthToken>

Token Validation

  • Auth.validateToken(token: string): Promise<boolean> - validate bearer token
  • Auth.getUserFromToken(token: string): Promise<UserModel | undefined>
  • Auth.currentAccessToken(): Promise<PersonalAccessToken | undefined>

Token Abilities (Scopes)

  • Auth.tokenCan(ability: string): Promise<boolean>
  • Auth.tokenCant(ability: string): Promise<boolean>
  • Auth.tokenAbilities(): Promise<string[]>
  • Auth.tokenCanAll(abilities: string[]): Promise<boolean>
  • Auth.tokenCanAny(abilities: string[]): Promise<boolean>

Token Management

  • Auth.tokens(userId?: number): Promise<PersonalAccessToken[]>
  • Auth.revokeToken(token: string): Promise<void>
  • Auth.revokeTokenById(tokenId: number): Promise<void>
  • Auth.revokeAllTokens(userId?: number): Promise<void>
  • Auth.revokeOtherTokens(userId?: number): Promise<void>
  • Auth.pruneExpiredTokens(): Promise<number>
  • Auth.pruneRevokedTokens(): Promise<number>
  • Auth.rotateToken(oldToken: string): Promise<AuthToken | null>
  • Auth.findToken(tokenId: number): Promise<PersonalAccessToken | null>

Utility

  • Auth.guard(name?: string): typeof Auth - select guard (returns self)
  • Auth.viaRemember(): boolean - always false currently
  • Auth.clearState(): void - clear cached user/token

Token System (tokens.ts)

Access Tokens

  • tokens(userId: number): Promise<AccessToken[]>
  • findToken(plainTextToken: string): Promise<AccessToken | null>
  • currentAccessToken(): Promise<AccessToken | null>
  • createToken(userId, name?, scopes?, options?): Promise<PersonalAccessTokenResult>
    • Options: { expiresInMinutes?, withRefreshToken?, refreshExpiresInDays? }

Refresh Tokens

  • refreshToken(refreshTokenPlain, options?): Promise<RefreshTokenResult>
  • validateRefreshToken(refreshTokenPlain): Promise<boolean>
  • revokeRefreshToken(refreshTokenPlain): Promise<void>
  • revokeAllRefreshTokens(userId): Promise<void>
  • deleteExpiredRefreshTokens(): Promise<number>
  • deleteRevokedRefreshTokens(daysOld?): Promise<number>

Token Revocation

  • revokeToken(plainTextToken): Promise<void>
  • revokeTokenById(tokenId): Promise<void>
  • revokeAllTokens(userId): Promise<void>
  • revokeOtherTokens(userId): Promise<void>
  • deleteExpiredTokens(): Promise<number>
  • deleteRevokedTokens(daysOld?): Promise<number>

Token Scopes

  • tokenCan(scope): Promise<boolean>
  • tokenCant(scope): Promise<boolean>
  • tokenCanAll(scopes): Promise<boolean>
  • tokenCanAny(scopes): Promise<boolean>
  • tokenAbilities(): Promise<string[]>
  • parseScopes(scopes: string | string[] | null | undefined): TokenScopes

OAuth Clients

  • clients(userId): Promise<OAuthClient[]>
  • findClient(clientId): Promise<OAuthClient | null>
  • createClient(options: CreateClientOptions): Promise<CreateClientResult>
  • revokeClient(clientId): Promise<void>

OAuth Provider and PKCE

The authorization server is opt-in through config/auth.ts under
oauthProvider. It is separate from social sign-in, where Stacks is the OAuth
client. The provider profile is Authorization Code with S256 PKCE and rotating
refresh tokens. Confidential client credentials are opt-in through
oauthProvider.clientCredentials and mint access-only tokens without refresh
tokens. Protected resource-server introspection is separately opt-in through
oauthProvider.introspection; it authenticates a confidential client and
requires that client to share a configured resource audience with the token.
It does not support implicit or password grants or OpenID Connect.

When enabled, the default auth route bundle registers:

  • GET and POST /oauth/authorize
  • POST /oauth/token
  • POST /oauth/revoke
  • GET /.well-known/oauth-authorization-server
  • owner-managed clients under /auth/oauth/clients
  • user-managed connected applications under /auth/oauth/connections

Set a canonical issuer, then register every scope and resource explicitly.
Resource audiences are absolute URIs and redirect URIs use exact matching.
The consent view is auth/oauth/consent by default and can be overridden with
oauthProvider.consent.view. Use oauthProvider.consent.resolveWorkspace when
the application must bind consent to current server-owned workspace authority.
Use oauthProvider.subjectEligibility when account status is represented by
application data that is not simply the presence of a user row. The callback
is rechecked before consent approval, delegated code exchange and refresh, and
by introspection; returning false revokes the affected grant and reports the
token inactive.

Provider actions return 404 while oauthProvider.enabled is false. The token
and revocation endpoints use protocol credentials and intentionally skip
browser CSRF. Authorization approval, client management, and disconnect remain
authenticated and CSRF protected.

  • resolveOAuthProviderConfig(options) returns null unless explicitly enabled
  • generatePkceVerifier() creates a 256-bit RFC 7636 verifier
  • createS256CodeChallenge(verifier) derives its S256 challenge
  • verifyS256CodeChallenge(verifier, challenge) validates without a plain fallback
  • isValidPkceVerifier(value) checks the required 43 to 128 character syntax

Two-Factor Authentication (authenticator.ts)

  • generateTwoFactorSecret(): string
  • generateTwoFactorToken(secret: Secret): Promise<Token>
  • verifyTwoFactorCode(token: Token, secret: Secret): Promise<boolean>
  • generateTwoFactorUri(user?, service?, secret?): string
  • createPersonalAccessClient(): Promise<Result<string, never>>

Re-exported from @stacksjs/ts-auth

  • generateTOTP, verifyTOTP, generateTOTPSecret, totpKeyUri

Authorization Gates (gate.ts)

Gate Functions

  • define<T>(ability: string, callback: GateCallback<T>): void
  • policy(model: string | { name }, policyClass: new () => Policy): void
  • before(callback): void - run before any gate check
  • after(callback): void - run after any gate check
  • allows(ability, user, ...args): Promise<boolean>
  • denies(ability, user, ...args): Promise<boolean>
  • can(ability, user, ...args): Promise<boolean>
  • cannot(ability, user, ...args): Promise<boolean>
  • any(abilities[], user, ...args): Promise<boolean>
  • all(abilities[], user, ...args): Promise<boolean>
  • none(abilities[], user, ...args): Promise<boolean>
  • authorize(ability, user, ...args): Promise<AuthorizationResponse> - throws on deny
  • inspect(ability, user, ...args): Promise<AuthorizationResponse> - never throws
  • has(ability): boolean
  • hasPolicy(model): boolean
  • abilities(): string[]
  • getPolicyFor<T>(model: T): Policy<T> | null
  • flush(): void - clear all gates

Gate Facade - Gate.define(), Gate.can(), etc

AuthorizationResponse Class

  • static allow(message?): AuthorizationResponse
  • static deny(message?, code?): AuthorizationResponse
  • allowed(): boolean, denied(): boolean
  • authorize(): void - throws AuthorizationException if denied

Policy Interface

Methods: before?, viewAny?, view?, create?, update?, delete?, restore?, forceDelete?

BasePolicy Abstract Class

Protected helpers: allow(message?), deny(message?, code?), denyIf(condition), denyUnless(condition), allowIf(condition)

RBAC System (rbac.ts)

Role Management

  • Rbac.createRole(name, guardName?, description?): Promise<RoleRecord>
  • Rbac.findRole(name, guardName?): Promise<RoleRecord | null>
  • Rbac.deleteRole(name, guardName?): Promise<void>
  • Rbac.getAllRoles(guardName?): Promise<RoleRecord[]>

Permission Management

  • Rbac.createPermission(name, guardName?, description?): Promise<PermissionRecord>
  • Rbac.findPermission(name, guardName?): Promise<PermissionRecord | null>
  • Rbac.deletePermission(name, guardName?): Promise<void>
  • Rbac.getAllPermissions(guardName?): Promise<PermissionRecord[]>

User-Role Operations

  • Rbac.getUserRoles(user): Promise<RoleRecord[]>
  • Rbac.assignRole(user, roleName, guardName?): Promise<void>
  • Rbac.removeRole(user, roleName, guardName?): Promise<void>
  • Rbac.removeAllRoles(user): Promise<void>
  • Rbac.syncRoles(user, roleNames[], guardName?): Promise<void> - replaces assignments for that guard and preserves roles from other guards
  • Rbac.hasRole(user, roleName, guardName?): Promise<boolean>
  • Rbac.hasAnyRole(user, roleNames[], guardName?): Promise<boolean>
  • Rbac.hasAllRoles(user, roleNames[], guardName?): Promise<boolean>

User-Permission Operations

  • Rbac.getUserPermissions(user): Promise<PermissionRecord[]>
  • Rbac.givePermission(user, permissionName, guardName?): Promise<void>
  • Rbac.revokePermission(user, permissionName, guardName?): Promise<void>
  • Rbac.revokeAllPermissions(user): Promise<void>
  • Rbac.syncPermissions(user, permissionNames[], guardName?): Promise<void>
  • Rbac.hasPermission(user, permissionName, guardName?): Promise<boolean>
  • Rbac.hasAnyPermission(user, permissionNames[], guardName?): Promise<boolean>
  • Rbac.hasAllPermissions(user, permissionNames[], guardName?): Promise<boolean>

Role-Permission Operations

  • Rbac.getRolePermissions(roleId): Promise<PermissionRecord[]>
  • Rbac.givePermissionToRole(roleName, permissionName, guardName?): Promise<void>
  • Rbac.revokePermissionFromRole(roleName, permissionName, guardName?): Promise<void>
  • Rbac.syncRolePermissions(roleName, permissionNames[], guardName?): Promise<void>

withRbac Mixin

withRbac(user) - adds hasRole(), hasPermission(), assignRole(), givePermission(), etc. to any user object

RBAC Types

interface RoleRecord { id, name, guard_name, description?, created_at?, updated_at? }
interface PermissionRecord { id, name, guard_name, description?, created_at?, updated_at? }
interface RbacStore { findRoleByName, createRole, deleteRole, getAllRoles, findPermissionByName, createPermission, ... }

Session Auth (session-auth.ts)

  • SessionAuth.login(email, password): Promise<{ user, sessionId }>
  • SessionAuth.logout(sessionId): Promise<void>
  • SessionAuth.user(sessionId): Promise<UserModel | undefined>
  • SessionAuth.check(sessionId): Promise<boolean>
  • SessionAuth.refresh(sessionId, ttlMs?): Promise<boolean>, rejects non-positive or non-finite TTLs without changing the session

Internal: database-backed sessions rows with expiry, optional IP/User-Agent fingerprint checks, transactional logout and refresh, and timing-safe password comparison with a dummy bcrypt hash. Sessions survive process restarts and are shared by workers through the configured database.

Email Verification (email-verification.ts)

  • EmailVerification.isVerified(user): boolean
  • EmailVerification.send(user): Promise<void>
  • EmailVerification.verify(userId, token): Promise<EmailVerificationResult>
  • EmailVerification.resend(user): Promise<EmailVerificationResult>

Password Reset (password/reset.ts)

const actions = passwordResets(email)
await actions.sendEmail()
const valid = await actions.verifyToken(token)
const result = await actions.resetPassword(token, newPassword)

Registration (register.ts)

  • register(credentials: NewUser): Promise<{ token: AuthToken }>

User Helpers (user.ts)

  • authUser(): Promise<UserModel | undefined>
  • check(): Promise<boolean>
  • id(): Promise<number | undefined>
  • email(): Promise<string | undefined>
  • name(): Promise<string | undefined>
  • isAuthenticated(): Promise<boolean>
  • logout(): Promise<void>
  • refresh(): Promise<void>

Passkey/WebAuthn

Opt in with config.auth.passkeys: { enabled: true, origin?: string, name?: string }.
The canonical origin defaults to config.app.url; production requires HTTPS.
buddy migrate / buddy auth:setup create passkey_ceremonies and passkeys.

  • beginPasskeyRegistration({ id, email?, name? }, { origin, name }) returns
    { ceremony, options } as JSON. Call only for the authenticated account.
  • finishPasskeyRegistration(userId, ceremony, credentialJSON, party) consumes
    the account-bound challenge and saves only verified resident credentials.
  • beginPasskeyAuthentication(party) returns discoverable options without an
    email lookup, usable with conditional mediation and autocomplete="username webauthn".
  • finishPasskeyAuthentication(ceremony, credentialJSON, party) returns the
    verified user ID; the application then issues its usual session.
  • listPasskeys(userId) returns display-safe metadata, never public-key material.
  • removePasskey(userId, id, hasAlternativeSignIn?) serializes removals and
    refuses to remove the last key without another login method. Supply a callback
    when password hashes may be unusable placeholders or other factors exist.

Default routes under /auth/passkeys expose registration/options, registration,
authentication/options, authentication (POST), listing (GET) and deletion (DELETE
/{id}). Public authentication endpoints require the canonical Origin header;
enrollment/removal also require auth and CSRF protection. Deleting the last key
requires proving the fallback password in the request. All responses are no-store.

Browser code imports @stacksjs/auth/passkeys-browser, never the server barrel.
It exports startRegistration, startAuthentication(options, { signal?, mediation? }),
browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, the JSON option/credential
codecs and types. Abort a pending conditional request before explicit sign-in or
password submission, and on unmount. Serialize results using
registrationCredentialToJSON / authenticationCredentialToJSON before POSTing.
Native JS contexts require a platform passkey bridge; web support alone does not
provide one. Keep existing native sign-in available until that bridge exists.

Legacy helpers (getUserPasskeys, getUserPasskey, storeWebAuthnChallenge,
consumeWebAuthnChallenge) remain for existing integrations. New flows should
use the ceremony API, which supports concurrent anonymous browser attempts.

Auth Middleware (middleware.ts)

export const authMiddlewareHandler = {
  name: 'auth',
  handle: authMiddleware, // validates bearer token, throws 401
}

Rate Limiter (rate-limiter.ts)

Every check/update is async, including when the backing store is memory:

import { RateLimiter } from '@stacksjs/auth'

await RateLimiter.validateAttempt(email)
await RateLimiter.recordFailedAttempt(email)
const locked = await RateLimiter.isRateLimited(email)
await RateLimiter.resetAttempts(email)

Five failed attempts lock an email for fifteen minutes. The configured store
selects atomic Redis when the cache config selects Redis, otherwise process-local
memory. useSharedStore(options?) explicitly chooses Redis;
useStore(custom) installs a store with atomic recordFailedAttempt;
useMemoryStore() and useConfiguredStore() reset that choice.
Shared deployments need a shared atomic store. Partial counters expire, and
cleanup thresholds are not hard capacities that evict live lockouts.

Authorizable Mixin (authorizable.ts)

const authUser = withAuthorization(user)
await authUser.can('edit-post', post)
await authUser.cannot('delete-post', post)
await authUser.canAny(['edit', 'delete'], post)
await authUser.canAll(['edit', 'publish'], post)
await authUser.authorize('edit-post', post)  // throws if denied

Configuration

config/auth.ts

const tokenExpiry = 60 * 60 * 1000
const authOptions = {
  default: 'api',
  guards: { api: { driver: 'token', provider: 'users' } },
  providers: { users: { driver: 'database', table: 'users' } },
  username: 'email',      // AUTH_USERNAME_FIELD env
  password: 'password',   // AUTH_PASSWORD_FIELD env
  tokenExpiry: 60 * 60 * 1000, // milliseconds, 1 hour
  refreshTokenExpiry: 30 * 24 * 60 * 60 * 1000, // milliseconds
  browserSession: {
    baselineLifetime: tokenExpiry, // absolute milliseconds, default one hour
    rememberedLifetime: tokenExpiry, // customize explicitly for a longer remembered tier
    withRefreshToken: false, // fixed browser lifetime, no unused refresh token
    logoutRedirect: '/login?logged_out=1', // local path for HTML logout only
  },
  defaultAbilities: ['*'],
  defaultTokenName: 'auth-token',
  passwordReset: { expire: 60, throttle: 60 }
}

browserSession applies to credentials issued by the default login,
registration, and completed two-factor actions. Dedicated personal access
token and OAuth issuance remain unchanged. The default login form sends
remember; registration uses the baseline tier unless a custom client sends
that field. A two-factor challenge preserves the choice without minting a
session until verification succeeds. Cookie Max-Age comes from the lifetime
returned by token issuance, so it cannot outlive the token. Cookie-authenticated
writes use the CSRF flow and same-origin credentials.

When migrating an app that copied framework auth actions, remove only the
equivalent login, registration, two-factor, logout, and cookie-helper overrides.
Retain application-specific onboarding and event hooks.

config/hashing.ts

{
  driver: 'bcrypt',         // 'bcrypt' | 'argon2'
  bcrypt: { rounds: 12 },
  argon2: { memory: 65536, time: 3 }
}

config/security.ts

{
  firewall: {
    enabled: true,
    countryCodes: [],
    ipAddresses: { allowlist: [], blocklist: [] },
    rateLimitPerMinute: 500,
    useIpReputationLists: true,
    useKnownBadInputsRuleSet: true
  }
}

Middleware Aliases (app/Middleware.ts)

Auth-relevant aliases: auth, guest, verified (EnsureEmailIsVerified),
abilities, can, role, permission, team, signed, throttle. The
environment aliases are env, env:local, env:development / env:dev,
env:staging, env:production / env:prod - with a COLON, not a dot; an
earlier version of this list wrote env.local and those never existed. See
stacks-middleware for the full set and for the !alias and alias:params
forms.

Application Gates (app/Gates.ts)

import { defineGates } from '@stacksjs/auth'

export default defineGates({
  gates: {
    'access-admin': user => user?.email?.endsWith('@stacksjs.com') ?? false,
    'edit-settings': user => !!user,
    'view-dashboard': user => !!user,
  },
  policies: {
    Post: 'PostPolicy',
  },
})

Registered at boot by initializeAuthorization(), from
injectGlobalAutoImports() - the one place every entry point comes through, so
HTTP, buddy seed, a scheduled job and a console command all get the same
gates.

Both halves of policies are checked: the key names a model the ORM exposes,
the value a policy file under app/Policies/ or the framework defaults. An
explicit mapping WINS over the <Model>Policy naming convention, which is the
reason to write one.

Gate.define(...) still works for a gate registered at runtime; defineGates
is the declarative form and the one the ability-name completions come from.

Default API Routes

  • POST /login → LoginAction (validates email + password)
  • POST /register → RegisterAction
  • POST /auth/refresh → RefreshTokenAction
  • POST /auth/token → CreateTokenAction
  • GET /auth/tokens → ListTokensAction (auth middleware)
  • DELETE /auth/tokens/{id} → RevokeTokenAction (auth middleware)
  • GET /me → GetMeAction (auth middleware)
  • POST /logout → LogoutAction (auth middleware)

User Model Traits

// User model uses:
traits: {
  useAuth: { usePasskey: true },
  useUuid: true,
  useTimestamps: true,
  useSocials: ['github'],
}

Gotchas

  • Auth depends on @stacksjs/ts-auth for TOTP and passkey functions
  • Password hashing defaults to bcrypt with 12 rounds (config/hashing.ts)
  • Rate limiting uses a process-local memory store by default. Production deployments with multiple workers should configure the atomic Redis store or provide a custom atomic store.
  • Session auth is database-backed through the sessions table, so it survives server restarts and is shared across workers.
  • New personal and delegated access tokens are opaque 40-byte hex bearers hashed at rest. Legacy jwt:encryptedId bearers remain readable during migration.
  • Token validation hashes the bearer directly. Do not parse or expose token contents, and never log plaintext bearer values.
  • Bearer tokens come from the Authorization: Bearer <token> header
  • Auth resolves the parsed bearer first and falls back to the configured token cookie; request user state is scoped rather than shared globally
  • RBAC has an internal cache (userRoles, userPermissions, rolePermissions) - call Rbac.flushCache() after direct DB changes
  • syncRoles() and syncPermissions() are guard-scoped replacements: they preserve assignments belonging to other guards
  • Gate before callbacks can short-circuit - return true to allow, null to continue checking
  • An ability with no gate and no policy method denies. That is the right default, and it means a gate that was never registered is indistinguishable from one that says no - which is how initializeAuthorization() went unnoticed while nothing called it
  • allows() and friends take Ability, which is open (GateName | PolicyAbility | (string & {})). A /can/:ability route passes an ability straight through, so narrowing it would reject correct code; the union is for completions
  • withRbac() and withAuthorization() return new objects with methods mixed in
  • RBAC lazily creates its SQL store. Rbac.setStore() is the optional override for a custom store; apply the role/permission model migrations before using it
  • Password reset tokens expire after 60 minutes by default
  • Default token abilities are ['*'] - wildcard access
  • Token expiry defaults to one hour; inspect config/auth.ts and browserSession before choosing a lifetime
  • Session auth uses timing-safe bcrypt comparison even for failed lookups (dummy hash prevents timing attacks)

Build

cd storage/framework/core/auth && bun build.ts

Native account workflows

Read ACCOUNT-WORKFLOWS.md when implementing magic links,
completed two-factor login, active teams, social account linking, role seeding,
referrals, cookie authentication or session idle/fingerprint policy. These are
native APIs with complete workflows, beyond the low-level TOTP/passkey helpers.

Provider and entrypoint boundaries

Default auth routes require the selected auth route bundle. feature('auth')
controls account-family model loading and does not itself mount login/register.
Opaque access tokens, OAuth grants, token cookies and database session IDs are
different credentials with different issuance and expiry rules.
Auth.guard() currently returns Auth rather than providing independent
guard implementations. viaRemember() is a compatibility false result;
browserSession's remember choice is the actual cookie lifetime policy.

Session and token user state are bound to the current request. Entry points
install that scope; application code must not cache a user globally for later
requests. Revocation-sensitive reads use the primary DB path. Retained evidence:
auth/tests/auth-middleware-entrypoints.test.ts,
request-token.test.ts, session-read-routing.test.ts,
rbac-default-store.test.ts and browser-session-policy.test.ts under
storage/framework/core/.

Remote MCP authorization

Use oauthProtectedResourceMetadata and oauthProtectedResourceMetadataPath
for RFC 9728 discovery, with the exact canonical resource URL as the audience.
The authorization server metadata advertises RFC 9207 issuer identification;
handleOAuthAuthorizationPageRequest and handleOAuthAuthorizationConsentRequest
include the provider issuer in success and error callbacks. Custom adapters using
oauthAuthorizationConsentResponse or oauthAuthorizationRequestErrorResponse
must pass provider.issuer too.

Opt in to RFC 7591 registration with oauthProvider.dynamicRegistration:
{ endpoint?: string, allowedRedirectOrigins: ['https://client.example'] }.
Origins must be explicit HTTPS origins. The native auth bundle mounts the endpoint
with bounded JSON, CSRF exemption for protocol clients and an IP quota. Custom
route registries can use handleDynamicOAuthRegistration(provider, metadata)
after the same bounded read and quota. Registration accepts only authorization
code / refresh grants, none or client_secret_basic, and exact validated
callback URIs. Anonymous clients have no account owner and no user access until
consent. It never fetches client-controlled URLs. CIMD and private_key_jwt are
not advertised.

Consent may include a scope form value containing a non-empty subset of the
requested scopes. approvedScopes on the native approval operation enforces
that subset within the authorization transaction. The native consent handler
can return { redirect_uri } JSON for an authenticated first-party SPA that
explicitly sends Accept: application/json; ordinary forms still receive a 302.
The browser-bound opaque request, authenticated subject and CSRF policy still
apply. Never construct callback URLs or accept an identity from browser input.

Token and refresh requests retain RFC 8707 resource parameters. When supplied,
they must match the complete approved audience set; this provider does not
silently issue a broader token for a narrower resource request. Scope narrowing
continues to use the scope parameter. MCP resource servers use
authorizeOAuthDelegatedToken with their exact resource, audience, workspace,
required scopes and current subject eligibility. Delegated grants are not login
sessions. Real persistence and HTTP evidence lives in the OAuth code-store and
HTTP lifecycle fixtures, with discovery policy in oauth-mcp-discovery.test.ts.

For an application-rendered consent UI, use createOAuthAuthorizationServer from
@stacksjs/auth. Supply the resolved provider, local consent page, ordinary
server-authenticated identity callback and optional exact connection audience.
It owns bounded registration/token/revocation reads, opaque browser-bound consent,
selected permissions, discovery and subject-owned disconnect. Mount protocol
POST handlers with parseBody(false) and skipCsrf(); keep router login and CSRF
protection on consent and connection management. Do not duplicate these HTTP
boundaries in applications or accept a subject from client input.

Delegated OAuth bearers are not ordinary login credentials. Auth.validateToken,
Auth.getUserFromToken, Auth.currentAccessToken and Auth.rotateToken reject
them even while their grant is active. Generic auth middleware therefore does
not authenticate them. OAuth resource endpoints must explicitly use
authorizeOAuthDelegatedToken with the exact resource, audience, workspace and
required scopes. Ordinary refresh also refuses delegated refresh credentials;
use the OAuth token endpoint and the registered client's protocol authentication.

Categories