Herman Stander
Core team developer and marketing
2025-11-15
Modern apps earn trust by putting the right guardrails in the right places. For routing, that means keeping authentication and authorization decisions close to the edges where requests first land, expressing policies declaratively, and composing them in small, testable units. In rwsdk, the building block for this is the interruptor: a tiny async function that can short‑circuit a request before it hits your page or action handler.
If you’ve ever scattered permission checks across components or buried them deep in handlers, you’ve felt the pain: rules drift, duplication creeps in, and reviewing access becomes guesswork. By moving these checks to the router with interruptors, policy becomes obvious, local, and easy to reason about—while still staying flexible enough to model roles, permissions, and “least privilege” access.
What follows is a practical blueprint you can lift into your app. We’ll define a couple of foundational interruptors for auth and admin gating, centralize permission helpers, compose them at the router, and shape context once so every check reads from the same source of truth.
Along the way we’ll link to the relevant rwsdk docs so you can dive deeper, and we’ll call out patterns that keep things robust as your surface area grows.
Interruptors are small async functions that run before a route’s handler. If they return a Response, rwsdk stops and returns it immediately—perfect for redirects (on unauthenticated access) or 403s (on authorization failures).
// src/shared/auth/interruptors.ts
import type { RequestInfo } from 'rwsdk/worker'
export async function requireAuth({ ctx }: RequestInfo) {
if (!ctx.user) {
return new Response(null, {
status: 302,
headers: { Location: '/auth/login' },
})
}
}
export async function requireAdmin({ ctx }: RequestInfo) {
const isAdmin = !!ctx.user && Array.isArray(ctx.user.roles) && ctx.user.roles.includes('admin')
if (!isAdmin) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
}
Rather than scattering ad‑hoc string checks everywhere, define a small set of permission utilities once. This keeps rules DRY and reviewable, and lets routes compose richer gatekeeping without repeating logic.
// src/shared/auth/permissions.ts
import type { RequestInfo } from 'rwsdk/worker'
export type Permission =
| 'account:read'
| 'account:write'
| 'user:read'
| 'user:delete'
export function can(ctx: any, permission: Permission): boolean {
const perms: string[] = ctx.user?.permissions ?? []
return perms.includes(permission)
}
export function cannot(ctx: any, permission: Permission): boolean {
return !can(ctx, permission)
}
export function requirePermission(permission: Permission) {
return async function requirePermissionInterruptor({ ctx }: RequestInfo) {
if (cannot(ctx, permission)) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
}
}
export function requireAnyPermission(...permissions: Permission[]) {
return async function requireAny({ ctx }: RequestInfo) {
const ok = permissions.some((p) => can(ctx, p))
if (!ok) return Response.json({ error: 'Forbidden' }, { status: 403 })
}
}
export function requireAllPermissions(...permissions: Permission[]) {
return async function requireAll({ ctx }: RequestInfo) {
const ok = permissions.every((p) => can(ctx, p))
if (!ok) return Response.json({ error: 'Forbidden' }, { status: 403 })
}
}
Attach interruptors directly to routes so policy is obvious where it matters most: at the edge. Your router becomes a living map of access rules—easy to scan, easy to test, and hard to bypass.
// src/app/pages/accounts/routes.ts
import { route } from 'rwsdk/router'
import { requireAuth } from 'src/shared/auth/interruptors'
import { requirePermission } from 'src/shared/auth/permissions'
import AccountsPage from './AccountsPage'
export default [
route('/accounts', [requireAuth, requirePermission('account:read'), AccountsPage]),
]
// src/admin/pages/users/routes.ts
import { route } from 'rwsdk/router'
import { requireAdmin } from 'src/shared/auth/interruptors'
import { requirePermission } from 'src/shared/auth/permissions'
import UsersPage from './UsersPage'
import DeleteUserAction from './DeleteUserAction'
export default [
route('/admin/users', [requireAdmin, requirePermission('user:read'), UsersPage]),
route('/admin/users/:id/delete', [requireAdmin, requirePermission('user:delete'), DeleteUserAction]),
]
Populate ctx.user once (in auth/session middleware) and let every interruptor rely on it. Centralizing identity and permissions in the request context keeps checks fast, deterministic, and consistent across the app.
// worker.tsx (excerpt)
export default defineApp([
async ({ ctx, request }) => {
// Example: load session and user once
const session = await sessions.load(request)
if (session?.user) ctx.user = session.user
},
// ...render routes...
])
Avoid hiding permission checks deep in components; keep them at the route edge so they’re unskippable and auditable. Use a broad gate like requireAdmin for /admin/* and layer fine‑grained requirePermission per route for true least privilege. Co‑locate interruptors and permission helpers under src/shared/auth/** to avoid cycles and keep discoverability high. And keep helpers small and deterministic—permission checks should not reach over the network.
If you’re new to composing routes in rwsdk, the router guide is a good companion read: see Routing and, specifically, Interruptors.
Use interruptors to keep auth and permissions declarative at the router. Centralize policy in small helpers like requirePermission, then compose per route for clarity, testability, and least‑privilege access. Your future self—and your security reviews—will thank you.