Auth & Access Control
How apps authenticate users and gate actions by role.
Authentication in this monorepo is centralized. Your app never embeds Better Auth or stores passwords. Instead, a shared Auth UI handles sign-in, and your app checks the session via middleware. Role-based restrictions (e.g. "only managers can edit recipes") are layered on top using shared database tables.
Architecture
Three pieces make this work:
| Piece | Location | Role |
|---|---|---|
| Auth API | shared/auth/auth-api/ | Better Auth + Postgres + Resend magic links. System service; do not modify. |
| Auth UI | shared/auth/auth-ui/ | Public sign-in pages, admin console, proxy to Auth API. |
| Your app | apps/<your-app>/ | Just calls Auth UI via middleware. No Better Auth code. |
Apps from the templates/web/ template already include an auth-ready middleware.ts. You typically don't need to touch it.
How the Session Check Works
On every request, the template's middleware:
- Calls
GET ${AUTH_UI_URL}/api/auth/get-session, forwarding the browser's cookies. - If unauthenticated and
AUTH_REQUIRED=true, redirects to${AUTH_UI_URL}/?returnTo=<current-url>. - After sign-in, Auth UI sends the user back; the session cookie is now set on
.up.railway.appand persists.
Environment Variables
| Variable | Where set | Purpose |
|---|---|---|
AUTH_UI_URL | Auto-injected on deploy | Base URL of the Auth UI service. |
AUTH_REQUIRED | Per-env config | Default true in production/staging; override per environment if needed. |
You don't set AUTH_UI_URL yourself — the deploy workflow injects it for every non-auth service. See Environment Variables.
Testing Auth in Preview
- Open a PR; wait for the PR comment with preview URL.
- Visit the preview URL → you're redirected to the staging Auth UI.
- Sign in (magic link).
- You're redirected back to the preview app with a session cookie.
No custom domains needed — everything runs on *.up.railway.app in preview.
Role-Based Access (Fine-Grained)
When you need "only role X can do action Y" (e.g. only managers can create/edit recipes), layer a permission check on top of the session.
Roles live in shared auth tables — your app does not define its own role enum:
| Table | Purpose |
|---|---|
allowed_user | Who can sign in. Has role (admin/user) and optional role_id. |
auth_role | Named roles like "manager", "chef". Admin-defined. |
auth_role_app | Which roles can open which apps. |
Permission Helper
Put a single helper in app/_lib/auth.ts that queries by email and returns a boolean:
import { getDb } from '@/app/_lib/db';
export async function checkIsManager(email: string): Promise<boolean> {
const db = getDb();
const result = await db.query<{ base_role: string; role_name: string | null }>(
`SELECT au.role AS base_role, ar.name AS role_name
FROM allowed_user au
LEFT JOIN auth_role ar ON au.role_id = ar.id
WHERE au.email = $1`,
[email]
);
if (!result.rowCount) return false;
const row = result.rows[0];
return row.base_role === 'admin' || row.role_name === 'manager';
}Using the Flag
- Server Component:
const isManager = await checkIsManager(session.user.email); pass as a prop to client components — don't duplicate the check in the browser. - API route (POST/PUT/DELETE): return
403 Forbiddenif the user lacks the role. - UI: conditionally render
{isManager && <Link href="/recipes/new">Add recipe</Link>}.
Reference App
apps/nani-cooking/ implements this end-to-end. Useful files to copy patterns from:
app/_lib/auth.ts—checkIsManagerhelperapp/api/recipes/route.ts— manager-gated APIapp/recipes/new/page.tsx— manager-only pagecomponents/RecipeCard.tsx— receivesisManageras a prop
Ask Claude
Quiz
Where do custom role names like 'manager' or 'chef' live?