@roostjs/auth Guides
Task-oriented instructions for authentication, authorization, sessions, and multi-tenancy.
How to protect routes with authentication
Add AuthMiddleware globally to require authentication on every route, or scope it to specific routes via a pipeline.
import { Application } from '@roostjs/core';
import { AuthMiddleware } from '@roostjs/auth';
import { AuthServiceProvider } from '@roostjs/auth';
const app = Application.create(env, {
auth: { redirectUrl: 'https://myapp.com/auth/callback' },
});
app.register(AuthServiceProvider);
app.useMiddleware(AuthMiddleware); // All routes now require authenticationUnauthenticated requests are redirected to /auth/login automatically. Pass return_to in the redirect to bring the user back after login:
// Link users to login with a return destination
<a href={'/auth/login?return_to=' + encodeURIComponent('/dashboard')}>Sign In</a>In TanStack Start, enforce authentication in route beforeLoad hooks for page routes:
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getCurrentUser } from '../functions/auth';
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const user = await getCurrentUser();
if (!user) throw redirect({ to: '/auth/login' });
},
component: DashboardPage,
});How to check user roles and permissions
Use RoleMiddleware to gate access by organization role. Pass one or more allowed roles as middleware arguments.
import { RoleMiddleware } from '@roostjs/auth';
// Only 'admin' role can access
app.useMiddleware(RoleMiddleware, 'admin');
// Multiple roles allowed
app.useMiddleware(RoleMiddleware, 'admin', 'moderator');For in-component checks, resolve the user from the session and inspect memberships:
const user = await sessionManager.resolveUser(request);
const isAdmin = user?.memberships.some(
(m) => m.organizationId === orgId && m.role === 'admin'
);
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
}How to implement multi-tenancy with organizations
Use OrgResolver to detect the tenant from the request. Pick a resolution strategy that fits your URL scheme.
import { OrgResolver } from '@roostjs/auth';
// Resolve from subdomain: acme.myapp.com → 'acme'
const resolver = new OrgResolver(['subdomain']);
// Or from path prefix: /org/acme/dashboard → 'acme'
const resolver = new OrgResolver(['path-prefix']);
// Or cascade: try subdomain first, then header
const resolver = new OrgResolver(['subdomain', 'header']);
const org = resolver.resolve(request);
if (!org) return new Response('Tenant not found', { status: 404 });
// org.slug is the organization identifier
const tenantData = await loadTenantData(org.slug);To direct a user to a specific organization's login, pass organization_id to the login route:
<a href={'/auth/login?organization_id=' + org.id}>Sign in to {org.name}</a>How to manage sessions
Sessions are stored in KV with a sliding TTL. Use SessionManager to create, resolve, and destroy sessions.
import { SessionManager } from '@roostjs/auth';
const sessionManager = new SessionManager(env.SESSION_KV, env.SESSION_SECRET);
// Create a session after successful auth
const { sessionId, response } = await sessionManager.createSession(workosUser, orgId);
// response has the Set-Cookie header — return it to the client
// Resolve the current user from an incoming request
const user = await sessionManager.resolveUser(request);
// user is null if session is missing or expired
// Destroy the session on logout
const logoutResponse = await sessionManager.destroySession(sessionId);
// logoutResponse clears the cookieHow to handle the OAuth callback
AuthServiceProvider registers /auth/callback automatically. You only need to ensure the route is listed in your WorkOS dashboard's allowed redirect URIs.
WORKOS_API_KEY=sk_test_...
WORKOS_CLIENT_ID=client_...app.register(AuthServiceProvider);
// /auth/login, /auth/callback, /auth/logout are now liveAfter the callback, the user is redirected to the URL in return_to, or to /dashboard if absent. To customize the post-login destination, set auth.defaultRedirect in your config.
How to connect OrgResolver to tenant-scoped ORM
OrgResolver detects which organization a request belongs to. TenantScopeMiddleware takes that result, looks up the org's database ID, and sets it on TenantContext so every model query in the request is automatically scoped. Wire them together once at application startup.
1. Register both service providers
import { AuthServiceProvider } from '@roostjs/auth';
import { OrmServiceProvider } from '@roostjs/orm';
import { Post, Organization } from './models';
app.register(AuthServiceProvider);
app.register(
new OrmServiceProvider().withModels([Post, Organization])
);2. Add tenant-scoped middleware
import { OrgResolver } from '@roostjs/auth';
import { TenantScopeMiddleware, TenantContext } from '@roostjs/orm';
import { Organization } from './models/Organization';
const resolver = new OrgResolver(['subdomain']); // or 'path-prefix', 'header'
const ctx = app.container.resolve(TenantContext);
app.useMiddleware(
new TenantScopeMiddleware(
resolver,
async (slug) =>
Organization.withoutTenantScope(() =>
Organization.where('slug', slug).first(),
),
ctx,
),
);The org lookup uses withoutTenantScope because Organization itself is a tenant-scoped model — looking up by slug must bypass the scope to find the org record before the context is populated.
3. Models are now automatically scoped
Any model with tenantColumn set will filter by the resolved org on every query, with no changes to controller or route code:
// org_id is injected automatically — no need to pass it
const posts = await Post.where('status', 'published').all();
const post = await Post.create({ title, body });Choosing a resolution strategy
| Strategy | URL pattern | When to use |
|---|---|---|
'subdomain' | acme.myapp.com | SaaS with per-org subdomains |
'path-prefix' | /org/acme/dashboard | Shared domain with org prefix |
'header' | X-Org-Slug: acme | API clients, internal services |
Strategies can be combined: new OrgResolver(['subdomain', 'header']) tries subdomain first, then falls back to the header.
How to add CSRF protection
Add CsrfMiddleware globally. All POST/PUT/PATCH/DELETE requests must include the _csrf token.
import { CsrfMiddleware } from '@roostjs/auth';
app.useMiddleware(CsrfMiddleware);Include the token in every form. Resolve the current CSRF token from the request context or set it as a cookie the client reads:
// In your form component, read the token from context
function MyForm({ csrfToken }: { csrfToken: string }) {
return (
<form method="POST" action="/submit">
<input type="hidden" name="_csrf" value={csrfToken} />
<button type="submit">Submit</button>
</form>
);
}
// For fetch/XHR, include the token in a header
await fetch('/api/resource', {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(data),
});CSRF middleware uses the double-submit cookie pattern. Requests missing a valid token receive a 403 Forbidden response.