Maturity-SE Onboarding Lesson 4 of 5
Two layers, one rule

Auth Guards: who needs login

This repo has a two-layer auth system. Learn the layers and you will never accidentally expose a protected route or block a public one. The rule: layout guards pages, getUserId() guards mutations.

8 min One win: trace any route to its auth guard in 10 seconds Mission
Layer 1 - the layout guard

app/(app)/layout.tsx - the gate

This 19-line file protects the entire (app) route group:

// app/(app)/layout.tsx - line 7-8
const session = await getSession()
if (!session?.user) redirect("/sign-in")

Every page under app/(app)/* is behind this gate. If not logged in, you get bounced to /sign-in before any page renders.

Protected by layout

/dashboard, /templates, /assessments, /gallery, /reports, /settings, /usage, /admin - everything in the (app) group.

Outside the gate

/sign-in, /sign-up, /respond/[token] - these live outside app/(app)/ and have no layout guard. Respondents never log in.

The rule: If your page is inside app/(app)/, it is protected. If you create a new route group or move a page outside (app)/, it becomes public.
Layer 2 - the action guard

getUserId() - the lock on mutations

Every server action starts with this call:

// lib/auth-helpers.ts
export async function getUserId(): Promise<string> {
  const session = await auth.api.getSession({ headers: await headers() })
  if (!session?.user) throw new Error("Unauthorized")
  return session.user.id
}

Even if someone bypasses the layout by calling a server action directly from curl, getUserId() checks the session cookie. If invalid, it throws Unauthorized.

Where it appears

// app/actions/templates.ts - first line of every action
const userId = await getUserId()

// app/actions/assessments.ts - same pattern
const userId = await getUserId()

// app/actions/llm-keys.ts - same pattern
const userId = await getUserId()
The exception: submitResponse in app/actions/assessments.ts does NOT call getUserId(). Respondents are anonymous - they only need a valid inviteToken.
The public route

/respond/[token] - no auth, just token

This route lives outside app/(app)/ and does two checks that are NOT auth:

// app/respond/[token]/page.tsx - lines 16-38
const assessment = await db.select().from(assessments)
  .where(eq(assessments.inviteToken, token)).limit(1)

if (!assessment) notFound()           // Check 1: token exists
if (assessment.status === 'closed')   // Check 2: assessment is active
  return "Assessment Closed"

The inviteToken is a 32-character nanoid (unguessable). No login required. The assessment owner controls access by toggling status between draft, active, and closed.

The design: Templates can be public (gallery), but assessments are always private to the owner. The inviteToken is the only way in. When you close an assessment, respondents see a dead end.
Practice - trace the guard

Which layer protects this route?

Q1

A user visits /templates/new without being logged in. What happens?

Q2

Someone calls saveTemplate server action via curl without a session cookie. What happens?

Go deeper

Primary source

Read lib/auth-helpers.ts (14 lines) and app/(app)/layout.tsx (19 lines). Together they are the entire auth system for pages and mutations.

Citations: layout guard from app/(app)/layout.tsx:7-8, action guard from lib/auth-helpers.ts:6-9, public route from app/respond/[token]/page.tsx:16-38.

What is next

Lesson 5 - End-to-end trace

Follow a template from creation through AI generation, assessment setup, invite link, response submission, to report aggregation. Every file touched, in order.

Stuck? Ask me - "how does cloneTemplate work?", "why does the report use Recharts?", "what happens if I delete a template with active assessments?" - I am your teacher.

Maturity-SE Onboarding - Glossary - Mission - Resources