@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 authentication

Unauthenticated 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 cookie

How 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 live

After 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

StrategyURL patternWhen to use
'subdomain'acme.myapp.comSaaS with per-org subdomains
'path-prefix'/org/acme/dashboardShared domain with org prefix
'header'X-Org-Slug: acmeAPI 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.