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:

PieceLocationRole
Auth APIshared/auth/auth-api/Better Auth + Postgres + Resend magic links. System service; do not modify.
Auth UIshared/auth/auth-ui/Public sign-in pages, admin console, proxy to Auth API.
Your appapps/<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:

  1. Calls GET ${AUTH_UI_URL}/api/auth/get-session, forwarding the browser's cookies.
  2. If unauthenticated and AUTH_REQUIRED=true, redirects to ${AUTH_UI_URL}/?returnTo=<current-url>.
  3. After sign-in, Auth UI sends the user back; the session cookie is now set on .up.railway.app and persists.

Environment Variables

VariableWhere setPurpose
AUTH_UI_URLAuto-injected on deployBase URL of the Auth UI service.
AUTH_REQUIREDPer-env configDefault 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

  1. Open a PR; wait for the PR comment with preview URL.
  2. Visit the preview URL → you're redirected to the staging Auth UI.
  3. Sign in (magic link).
  4. 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:

TablePurpose
allowed_userWho can sign in. Has role (admin/user) and optional role_id.
auth_roleNamed roles like "manager", "chef". Admin-defined.
auth_role_appWhich roles can open which apps.

Permission Helper

Put a single helper in app/_lib/auth.ts that queries by email and returns a boolean:

app/_lib/auth.tstypescript
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 Forbidden if 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.tscheckIsManager helper
  • app/api/recipes/route.ts — manager-gated API
  • app/recipes/new/page.tsx — manager-only page
  • components/RecipeCard.tsx — receives isManager as a prop

Ask Claude

Add role-based restriction to an action
Claude prompt
In my meal-planner app, only users with the "manager" role should be able to create or delete recipes. Add the permission helper in app/_lib/auth.ts, protect the API routes, and hide the "New Recipe" button in the UI unless the user is a manager. Follow the pattern from nani-cooking.

Quiz

Quiz

Where do custom role names like 'manager' or 'chef' live?