Auth Package
On this page 44
A comprehensive authentication system providing Laravel Passport-style token management, OAuth2 support, authorization gates/policies, WebAuthn/Passkey support, and two-factor authentication.
Installation
bun add @stacksjs/auth
Basic Usage
import { Auth } from '@stacksjs/auth'
// Login with credentials
const result = await Auth.login({
email: 'user@example.com',
password: 'password123'
})
if (result) {
console.log('Token:', result.token)
console.log('User:', result.user)
}
// Check authentication
if (await Auth.check()) {
const user = await Auth.user()
console.log('Authenticated as:', user.name)
}
// Logout
await Auth.logout()
Configuration
Configure authentication in config/auth.ts:
export default {
// Credential field names
username: 'email',
password: 'password',
// Token configuration
// The LABEL written to a token's `name` column — what a "your active
// sessions" list shows. Not the cookie name; see `cookie` below.
defaultTokenName: 'auth-token',
defaultAbilities: ['*'],
// The browser cookie that carries an access token.
cookie: {
name: 'auth-token',
path: '/',
sameSite: 'Lax',
// domain: '.example.com',
// secure: true, // defaults from the configured app URL
},
tokenExpiry: 30 * 24 * 60 * 60 * 1000, // 30 days
// Password requirements
password: {
minLength: 8,
requireUppercase: true,
requireNumbers: true,
requireSpecialChars: false,
},
// Rate limiting
rateLimit: {
maxAttempts: 5,
decayMinutes: 15,
},
}
Authentication Methods
Login
import { Auth } from '@stacksjs/auth'
// Basic login
const result = await Auth.login({
email: 'user@example.com',
password: 'password123'
})
// Login with custom token options
const result = await Auth.login(
{ email: 'user@example.com', password: 'secret' },
{
name: 'mobile-app',
abilities: ['read', 'write'],
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
}
)
Login Using ID
import { Auth } from '@stacksjs/auth'
// Login user by ID (useful for impersonation or social auth)
const result = await Auth.loginUsingId(userId, {
name: 'social-login',
abilities: ['*']
})
Validate Without Login
import { Auth } from '@stacksjs/auth'
// Check credentials without creating session
const isValid = await Auth.validate({
email: 'user@example.com',
password: 'password123'
})
Attempt Authentication
import { Auth } from '@stacksjs/auth'
// Attempt auth and store user
const success = await Auth.attempt({
email: 'user@example.com',
password: 'password123'
})
if (success) {
const user = await Auth.user()
}
One-Time Authentication
import { Auth } from '@stacksjs/auth'
// Authenticate for single request only
const success = await Auth.once({
email: 'user@example.com',
password: 'password123'
})
// No token is created
User & Auth State
Getting Current User
import { Auth } from '@stacksjs/auth'
// Get authenticated user
const user = await Auth.user()
// Get user ID
const userId = await Auth.id()
// Check if authenticated
const isAuthenticated = await Auth.check()
// Check if guest
const isGuest = await Auth.guest()
Setting User (Testing)
import { Auth } from '@stacksjs/auth'
// Set user for testing
Auth.setUser(mockUser)
// Clear auth state
Auth.clearState()
Token Management
Creating Tokens
import { Auth } from '@stacksjs/auth'
// Create token for user
const { accessToken, plainTextToken } = await Auth.createTokenForUser(user, {
name: 'api-token',
abilities: ['posts:read', 'posts:write'],
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
})
// Simple token creation
const token = await Auth.createToken(user, 'my-token', ['*'])
Validating Tokens
import { Auth } from '@stacksjs/auth'
// Validate a token
const isValid = await Auth.validateToken(bearerToken)
// Get user from token
const user = await Auth.getUserFromToken(bearerToken)
Token Abilities
import { Auth } from '@stacksjs/auth'
// Check if token has ability
if (await Auth.tokenCan('posts:write')) {
// Can write posts
}
// Check if token lacks ability
if (await Auth.tokenCant('admin:access')) {
// Cannot access admin
}
// Check multiple abilities
const canAll = await Auth.tokenCanAll(['read', 'write'])
const canAny = await Auth.tokenCanAny(['admin', 'moderator'])
// Get all abilities
const abilities = await Auth.tokenAbilities()
Current Access Token
import { Auth } from '@stacksjs/auth'
// Get current token instance
const token = await Auth.currentAccessToken()
console.log(token.name)
console.log(token.abilities)
console.log(token.expiresAt)
Managing User Tokens
import { Auth } from '@stacksjs/auth'
// Get all tokens for user
const tokens = await Auth.tokens(userId)
// Find specific token
const token = await Auth.findToken(tokenId)
// Revoke specific token
await Auth.revokeToken(tokenString)
await Auth.revokeTokenById(tokenId)
// Revoke all tokens
await Auth.revokeAllTokens(userId)
// Revoke all except the request's own token (or an explicit one). With no
// current token (a cookie-session request) every token is revoked.
const { keptTokenId } = await Auth.revokeOtherTokens(userId)
await Auth.revokeOtherTokens(userId, { exceptTokenId: 12 })
// Rotate token (refresh)
const newToken = await Auth.rotateToken(oldToken)
Rotation is explicit because the caller must return the replacement bearer to the client. Validation never rotates a token behind the client's back. Use a refresh-token exchange for ordinary long-lived sessions.
Individual revocation updates an access token and its paired refresh tokens in
one transaction. If storage rejects either update, neither change is committed
and the error reaches the caller. logoutCookie() only clears the browser
cookie after revocation succeeds, preserving the credential for a retry on
failure. Other sessions are not changed. See #2610.
An already-rotated token does not identify its replacement in the current
schema. Revoking a predecessor is not a sign-out of that replacement; use
revokeAllTokens when the intent is to revoke every session for the owner.
Token Cleanup
import { Auth } from '@stacksjs/auth'
// Delete expired tokens
const expiredCount = await Auth.pruneExpiredTokens()
// Delete revoked tokens
const revokedCount = await Auth.pruneRevokedTokens()
Middleware
Auth Middleware
import { route } from '@stacksjs/router'
// Protect routes with auth middleware
route.get('/profile', 'Actions/Profile').middleware('auth')
route.group({ middleware: 'auth' }, () => {
route.get('/dashboard', 'Actions/Dashboard')
route.get('/settings', 'Actions/Settings')
})
Abilities Middleware
route.get('/posts', 'Actions/ListPosts').middleware('abilities:posts:read')
route.post('/posts', 'Actions/CreatePost').middleware('abilities:posts:write')
route.delete('/posts/:id', 'Actions/DeletePost').middleware('abilities:posts:delete')
Custom Middleware
// app/Middleware/AdminMiddleware.ts
import { Auth } from '@stacksjs/auth'
export default {
async handle(req: EnhancedRequest) {
const user = await Auth.user()
if (!user || user.role !== 'admin') {
throw { statusCode: 403, message: 'Forbidden' }
}
}
}
Authorization (Gates & Policies)
Defining Gates
import { Gate } from '@stacksjs/auth'
// Define authorization gates
Gate.define('edit-post', async (user, post) => {
return user.id === post.userId
})
Gate.define('delete-post', async (user, post) => {
return user.id = post.userId || user.role = 'admin'
})
Gate.define('admin-access', async (user) => {
return user.role === 'admin'
})
Using Gates
import { Gate } from '@stacksjs/auth'
// Check authorization
if (await Gate.allows('edit-post', post)) {
// User can edit
}
if (await Gate.denies('delete-post', post)) {
// User cannot delete
}
// Authorize or throw
await Gate.authorize('admin-access')
// Throws 403 if not authorized
Policies
// app/Policies/PostPolicy.ts
import { Policy } from '@stacksjs/auth'
export default class PostPolicy extends Policy {
async view(user: User, post: Post) {
return post.published || user.id === post.userId
}
async create(user: User) {
return user.emailVerified
}
async update(user: User, post: Post) {
return user.id === post.userId
}
async delete(user: User, post: Post) {
return user.id = post.userId || user.role = 'admin'
}
}
Using Policies
import { Gate } from '@stacksjs/auth'
// Register policy
Gate.policy(Post, PostPolicy)
// Use policy
if (await Gate.allows('update', post)) {
// Can update
}
// In route handler
route.put('/posts/:id', async (req) => {
const post = await Post.find(req.params.id)
await Gate.authorize('update', post)
// Update post...
})
Two-Factor Authentication (TOTP)
Setting Up 2FA
import { generateTwoFactorSetup } from '@stacksjs/auth'
// Secret, otpauth:// URI and QR code in one synchronous call
const { secret, uri, qr } = generateTwoFactorSetup(user.email, 'MyApp')
// `qr` is an SVG string, not a data URI: it stays sharp at any size, inlines
// into a server-rendered page or an email, and is a few hundred bytes rather
// than tens of kilobytes.
The pieces are also available individually - generateTwoFactorSecret(),
generateTwoFactorUri(email, serviceName, secret) and
twoFactorQrCode(uri, size?) - for a flow that needs to interleave its own
steps. stashPendingTwoFactorSecret holds the secret server-side while the
user scans it, so it is never enabled from an unconfirmed code.
Verifying 2FA Code
import { verifyTOTP } from '@stacksjs/auth'
const isValid = verifyTOTP({
token: userInputCode,
secret: user.totpSecret
})
if (isValid) {
// Code is correct
}
2FA in Login Flow
import { Auth, verifyTOTP } from '@stacksjs/auth'
route.post('/login', async (req) => {
const { email, password, twoFactorCode } = req.all()
// Validate credentials
const valid = await Auth.attempt({ email, password })
if (!valid) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 })
}
const user = await Auth.user()
// Check if 2FA is enabled
if (user.totpSecret) {
if (!twoFactorCode) {
return Response.json({ requiresTwoFactor: true }, { status: 200 })
}
const validCode = verifyTOTP({
token: twoFactorCode,
secret: user.totpSecret
})
if (!validCode) {
return Response.json({ error: 'Invalid 2FA code' }, { status: 401 })
}
}
// Create token and return
const { plainTextToken } = await Auth.createTokenForUser(user)
return Response.json({ token: plainTextToken })
})
WebAuthn / Passkeys
Registration
import {
generateRegistrationOptions,
verifyRegistrationResponse
} from '@stacksjs/auth'
// Generate registration options
route.get('/webauthn/register/options', async (req) => {
const user = await req.user()
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'myapp.com',
userID: user.id.toString(),
userName: user.email,
userDisplayName: user.name,
attestationType: 'none',
})
// Store challenge for verification
await cache.set(`webauthn:challenge:${user.id}`, options.challenge, 300)
return Response.json(options)
})
// Verify registration
route.post('/webauthn/register', async (req) => {
const user = await req.user()
const response = req.all()
const challenge = await cache.get(`webauthn:challenge:${user.id}`)
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
})
if (verification.verified) {
// Store credential
await user.passkeys().create({
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
})
}
})
Authentication
import {
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@stacksjs/auth'
// Generate auth options
route.post('/webauthn/login/options', async (req) => {
const { email } = req.all()
const user = await User.where('email', '=', email).first()
const passkeys = await user.passkeys().get()
const options = await generateAuthenticationOptions({
rpID: 'myapp.com',
allowCredentials: passkeys.map(p => ({
id: p.credentialId,
type: 'public-key',
})),
})
await cache.set(`webauthn:auth:${user.id}`, options.challenge, 300)
return Response.json(options)
})
// Verify authentication
route.post('/webauthn/login', async (req) => {
const { email, response } = req.all()
const user = await User.where('email', '=', email).first()
const challenge = await cache.get(`webauthn:auth:${user.id}`)
const passkey = await user.passkeys()
.where('credentialId', '=', response.id)
.first()
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
authenticator: {
credentialID: passkey.credentialId,
credentialPublicKey: passkey.publicKey,
counter: passkey.counter,
},
})
if (verification.verified) {
// Update counter
await passkey.update({ counter: verification.authenticationInfo.newCounter })
// Login user
const { plainTextToken } = await Auth.createTokenForUser(user)
return Response.json({ token: plainTextToken })
}
})
Password Reset
passwordResets(email) returns the three actions for that address, so the
email is never a parameter you can get wrong halfway through the flow:
import { passwordResets } from '@stacksjs/auth'
import { response } from '@stacksjs/router'
// Request password reset
route.post('/forgot-password', async (req) => {
const { email } = req.all()
// Creates the token, invalidating any outstanding one for this address,
// and sends the mail. The expiry comes from config.auth.passwordReset.expire.
await passwordResets(email).sendEmail()
return response.json({ message: 'Reset email sent' })
})
// Reset password
route.post('/reset-password', async (req) => {
const { email, token, password } = req.all()
const result = await passwordResets(email).resetPassword(token, password)
if (result.success)
return response.json({ message: 'Password reset successfully' })
return response.json({ error: result.error }, { status: 400 })
})
verifyToken(token) checks a token without consuming it, for a reset page that
wants to show the form only when the link is still valid.
sendEmail() takes options for apps with their own templates or a delivery
ledger, and for accounts someone else created:
await passwordResets(email).sendEmail({
purpose: 'setup', // 'reset' (default) or 'setup': a first password for an invited account
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60_000),
// Runs after the token commits, instead of the framework mail.
deliver: async ({ purpose, userId, email, token, url, expiresAt }) => {
await sendLocalizedMail(email, { url, expiresAt, purpose })
},
})
token and url are the live credential: never log them. An unknown or
throttled address is still a silent no-op, so the endpoint cannot be used to
enumerate accounts.
Changing a password, and the credential sweep
A successful reset stamps password_changed_at (every token issued before it
is refused) and revokes two-factor challenges, magic links, OAuth grants and
codes, tokens and sessions, in one transaction. That sweep is exported, so
every other path that changes how an account proves who it is uses the same
one:
import { changePassword, revokeAccountCredentials } from '@stacksjs/auth'
// Signed-in user changing their password: keep this device, sign out the rest.
const current = await Auth.currentAccessToken()
const result = await changePassword(user.id, req.get('current_password'), req.get('password'), {
keepTokenId: current?.id,
})
if (!result.success)
return response.json({ error: result.reason }, { status: 422 })
// An admin forcing a sign-out everywhere, inside your own transaction.
await db.transaction(async (tx) => {
await revokeAccountCredentials(user.id, { tx })
})
changePassword verifies the current password, commits only if that hash is
still the stored one, runs the sweep and, after commit, sends the
password-changed mail (pass notify: false or your own function). Enforce
your password policy before calling it.
Rate Limiting
The login, magic-link and two-factor paths lock an address after five failures for fifteen minutes. The same limiter is available directly:
import { RateLimiter } from '@stacksjs/auth'
route.post('/login', async (req) => {
const { email } = req.all()
// Throws HttpError(429) while the address is locked.
await RateLimiter.validateAttempt(email)
const success = await Auth.attempt(req.all())
if (!success) {
await RateLimiter.recordFailedAttempt(email)
return Response.json({ error: 'Invalid credentials' }, { status: 401 })
}
await RateLimiter.resetAttempts(email)
// Continue with login...
})
When config/cache.ts uses the Redis driver, attempts are counted there, so a
lockout holds across every worker and instance; while Redis is unreachable the
limiter falls back to process memory and warns once. Without Redis it counts per
process. config.security.rateLimit.scale (or STACKS_RATE_LIMIT_SCALE)
multiplies the threshold, and every route .rateLimit(), so a test suite can
raise it instead of turning limits off.
Social sign-in
The social route bundle mounts three routes when a provider is configured in
config/services.ts:
| Route | What it does |
|---|---|
GET /auth/{provider} | Issues a single-use OAuth state, bound to this browser by an HttpOnly cookie, and redirects to the provider. ?redirect_to=/path picks the landing page. |
GET|POST /auth/{provider}/callback | Redeems that state (a code from another browser is refused before it is exchanged), resolves the user, and redirects to redirect_to#stx_handoff=<code>. |
POST /auth/handoff | Trades the one-use code (60 seconds, this browser only) for the session: the token pack and the auth cookie. |
useAuth().completeSocialLogin() on the landing page does the last step. No
bearer or refresh token ever appears in a URL.
resolveSocialSignIn() decides which local user an identity is. It links to an
existing account by email only when the provider verified the address and the
local account verified it too; otherwise it throws SocialSignInRefusedError
with reason 'unverified-provider-email' or 'unverified-local-email'. A new
account made from a provider-verified address starts verified.
For a custom flow, use the same primitives:
import { createSessionHandoff, redeemSessionHandoff } from '@stacksjs/auth'
import { consumeOAuthFlow, issueOAuthFlow, readOAuthFlowCookie, socialHandoffCodeRedirect } from '@stacksjs/socials'
// Start: send `flow.cookie` with the redirect.
const flow = await issueOAuthFlow({ purpose: 'social:google', ttlMs: 10 * 60_000 })
driver.withState(flow.state)
// Callback: null for a forged, replayed, expired or foreign-browser state.
const started = await consumeOAuthFlow(state, {
purpose: 'social:google',
browserSecret: readOAuthFlowCookie(request.headers.get('cookie'), state),
})
// Hand over a code, not tokens.
const handoff = await createSessionHandoff(userId, { bindTo: 'cookie' })
const redirect = socialHandoffCodeRedirect(handoff.code, { redirectTo: '/account' })
redirect.headers.append('Set-Cookie', handoff.cookie!)
A native app whose sign-in sheet keeps its own cookies binds the handoff to a
verifier instead: createSessionHandoff(userId, { bindTo: { challenge: sha256Hex(verifier) } }),
redeemed with redeemSessionHandoff(code, verifier).
API Reference
Auth Class Methods
| Method | Description |
|---|---|
attempt(credentials) | Authenticate and store user |
validate(credentials) | Validate without login |
login(credentials, options?) | Login and get token |
loginUsingId(id, options?) | Login by user ID |
logout() | Logout current user |
once(credentials) | One-time authentication |
user() | Get authenticated user |
id() | Get user ID |
check() | Check if authenticated |
guest() | Check if guest |
setUser(user) | Set user (testing) |
createTokenForUser(user, options?) | Create token |
validateToken(token) | Validate token |
tokenCan(ability) | Check token ability |
tokenCant(ability) | Check token lacks ability |
tokens(userId?) | Get user's tokens |
revokeToken(token) | Revoke token |
revokeAllTokens(userId?) | Revoke all tokens |
Gate Methods
| Method | Description |
|---|---|
define(name, callback) | Define gate |
allows(ability, ...args) | Check if allowed |
denies(ability, ...args) | Check if denied |
authorize(ability, ...args) | Authorize or throw |
policy(model, policy) | Register policy |
TOTP Functions
| Function | Description |
|---|---|
generateTOTPSecret() | Generate secret |
verifyTOTP(options) | Verify code |
totpKeyUri(options) | Generate URI |
generateQRCodeDataURL(uri) | Generate QR code |
generateQRCodeSVG(uri) | Generate SVG QR |
Cookie sessions for the browser
An API client holds a bearer token in a header. A browser coming back from an
OAuth redirect or a form POST has nothing but cookies, so @stacksjs/auth
carries the same personal access token in an HttpOnly cookie:
import { Auth, authCookie, clearAuthCookie } from '@stacksjs/auth'
const session = await Auth.loginUsingId(user.id)
return new Response(null, {
status: 303,
headers: {
'Location': '/account',
'Set-Cookie': authCookie(String(session.token)),
},
})
The cookie is HttpOnly (no page script needs it), SameSite=Lax (a link from
an email arrives signed in, a cross-site POST does not) and Secure unless the
configured app URL is a plain-HTTP loopback host. Configure its name, path,
domain, SameSite, or explicit Secure override through config.auth.cookie.
Issuance and clearAuthCookie() share those attributes, so logout removes the
same cookie. It carries a real token, so it is revocable through the usual token
calls and survives a restart.
The Auth middleware reads it automatically — bearer header first, then this
cookie. authCookieName() is the single resolver both sides use, so the name
written is always the name read. Until stacksjs/stacks#2236 it was not:
authCookie() wrote stacks_auth while every reader looked for
defaultTokenName, and the whole path was quietly inert.
On the client, finish the handoff instead of writing storage keys by hand:
const { completeSocialLogin } = useAuth()
await completeSocialLogin()