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/.
Resources
1Install
npx skillscat add stacksjs/stacks/stacks-auth Install via the SkillsCat registry.
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 authAuth Class (authentication.ts) - Static Methods
Login & Authentication
Auth.attempt(credentials: AuthCredentials): Promise<boolean>- validate credentials without creating tokenAuth.validate(credentials: AuthCredentials): Promise<boolean>- checks credentials without setting the request's authenticated userAuth.login(credentials: AuthCredentials, options?: TokenCreateOptions): Promise<{ user, token } | null>- login and create tokenAuth.loginUsingId(userId: number, options?: TokenCreateOptions): Promise<{ user, token } | null>- login by user IDAuth.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.tokenableTypedefaults tousers.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 tokenAuth.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 stateAuth.check(): Promise<boolean>- is user authenticated?Auth.guest(): Promise<boolean>- is user a guest?Auth.id(): Promise<number | undefined>- get authenticated user IDAuth.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 tokenAuth.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 currentlyAuth.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? }
- Options:
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 underoauthProvider. 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 throughoauthProvider.clientCredentials and mint access-only tokens without refresh
tokens. Protected resource-server introspection is separately opt-in throughoauthProvider.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:
GETandPOST /oauth/authorizePOST /oauth/tokenPOST /oauth/revokeGET /.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 withoauthProvider.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)returnsnullunless explicitly enabledgeneratePkceVerifier()creates a 256-bit RFC 7636 verifiercreateS256CodeChallenge(verifier)derives its S256 challengeverifyS256CodeChallenge(verifier, challenge)validates without a plain fallbackisValidPkceVerifier(value)checks the required 43 to 128 character syntax
Two-Factor Authentication (authenticator.ts)
generateTwoFactorSecret(): stringgenerateTwoFactorToken(secret: Secret): Promise<Token>verifyTwoFactorCode(token: Token, secret: Secret): Promise<boolean>generateTwoFactorUri(user?, service?, secret?): stringcreatePersonalAccessClient(): 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>): voidpolicy(model: string | { name }, policyClass: new () => Policy): voidbefore(callback): void- run before any gate checkafter(callback): void- run after any gate checkallows(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 denyinspect(ability, user, ...args): Promise<AuthorizationResponse>- never throwshas(ability): booleanhasPolicy(model): booleanabilities(): string[]getPolicyFor<T>(model: T): Policy<T> | nullflush(): void- clear all gates
Gate Facade - Gate.define(), Gate.can(), etc
AuthorizationResponse Class
static allow(message?): AuthorizationResponsestatic deny(message?, code?): AuthorizationResponseallowed(): boolean,denied(): booleanauthorize(): 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 guardsRbac.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): booleanEmailVerification.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 andautocomplete="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 usingregistrationCredentialToJSON / 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 deniedConfiguration
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 sendsremember; 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. Seestacks-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(), frominjectGlobalAutoImports() - 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→ RegisterActionPOST /auth/refresh→ RefreshTokenActionPOST /auth/token→ CreateTokenActionGET /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-authfor 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
sessionstable, 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:encryptedIdbearers 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) - callRbac.flushCache()after direct DB changes syncRoles()andsyncPermissions()are guard-scoped replacements: they preserve assignments belonging to other guards- Gate
beforecallbacks can short-circuit - returntrueto allow,nullto 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 takeAbility, which is open (GateName | PolicyAbility | (string & {})). A/can/:abilityroute passes an ability straight through, so narrowing it would reject correct code; the union is for completionswithRbac()andwithAuthorization()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.tsNative 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 understorage/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 usingoauthAuthorizationConsentResponse 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 useauthorizeOAuthDelegatedToken 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 useauthorizeOAuthDelegatedToken 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.