Skip to content
Chapter 80Lesson 3

Walking the six error seams

A map of the six boundaries in the app where the fail-closed and two-message error rules must land, the wrapper that owns each one, and the grep that finds the code that skipped it.

You now have two rules and a name for each. Fail closed means that when a gate can’t prove a request is allowed, it refuses, and a throw inside the check counts as a refusal, not an accidental yes. Two messages means that every error forks into a sanitized userMessage for the person and a rich operator record for the engineer, and that fork happens at the wrapper, never at the screen.

The previous lesson left you a portable test you can carry to any line of code: does this fail closed, and is what the user sees safe to read aloud on a support call? That test is precise, but it is also local. It tells you whether one path is sound; it doesn’t tell you where all the paths are. A junior runs the test on the code they happen to be looking at. An experienced engineer runs it across the whole surface, on purpose, knowing there is a fixed number of places it has to land.

That is this lesson’s job. You have the rules; now you point at every place in the app where they land. There are exactly six: six wrappers, helpers, and boundaries, each one owning both commitments, each one with a grep pattern that finds the code that slipped past it. Learn the six and you have a map of the app’s entire error surface. That map is what the pre-launch audit project, two chapters from here, turns into a checklist.

Here are all six at once, so you can see the overall shape before we walk through them.

For each seam: does it fail closed, and is what the user sees safe to read aloud on a support call? safeLimit is the one documented exception — it fails open on a Redis outage, on purpose.

Each card is a seam. From here the lesson is a walk through them one card at a time, asking the same four questions at every stop: where the seam lives, where fail-closed lands, where message-split lands, and the audit step. The audit step is what to grep for to find the path that skipped the seam, and how to triage what you find. Learn the rhythm once at the first seam and the other five are pattern-matching.

Seam 1: authedAction, the Server Action boundary

Section titled “Seam 1: authedAction, the Server Action boundary”

We start at seam #1 because it’s the one you know best: the canonical Server Action wrapper from the organizations work, the one you re-read in both previous lessons. The four questions you answer here are the template for all six, so the attention you spend on this seam pays for the rest of the walk.

Where it lives. authedAction(role, schema, fn) wraps every Server Action across your actions.ts files. It returns a (formData) => Promise<Result<T>> and hands your fn a ctx of { user, orgId, role, db }. One wrapper covers every action. That uniformity is what makes the whole audit possible: the gate logic lives in exactly one place, so there is exactly one place to lint and one thing to grep for the absence of.

Where fail-closed lands. Walk the wrapper’s steps and notice that every exit on a failed gate is a refusal. The action body never runs unless every gate passed. If the schema’s safeParse fails, the wrapper returns mapError(input.error), a validation Result. If requireOrgUser() finds no session or no active org, it throws, and that throw flies straight to the framework’s nearest error.tsx, so the user gets the boundary, not the resource. If the separate role gate, roleAtLeast(role), sees a role beneath the bar, that’s an expected refusal, so the wrapper returns err('forbidden', …) rather than throwing. If your fn throws, it’s caught in the wrapper’s single try/catch, captured, and returned as an internal Result. The mechanisms differ: a returned validation error, a thrown session failure, a returned forbidden refusal, a caught fn error. They all land on the same outcome, which is to refuse. There is no path through the wrapper where a gate fails and the body runs.

Where message-split lands. It lands in that same try/catch. The catch does the two moves from the previous lesson, in order: first it writes the operator record, then it produces the user side.

You read the full wrapper last lesson; here is the half that carries both commitments, the operator capture followed by the user-side map.

lib/authed-action.ts
} catch (error) {
logger.error({ action, userId, orgId, role, input: redact(input), err: error });
Sentry.captureException(error);
return mapError(error);
}

The raw error.message reaches the log and Sentry; it never reaches the userMessage. Both commitments live in this one block: capture for the operator, mapError for the person. That block is the entire reason the wrapper exists. You don’t apply the split at each action; the wrapper applies it once, for all of them.

Audit step. This is the part that makes the six a map rather than a lecture. The seam owns both commitments only for the code that actually goes through it. A Server Action that skips authedAction has no gate at all: no role check, no capture, no mapError. So the finding you hunt for is a 'use server' file that never imports the wrapper.

Terminal window
# Server Action files that never route through authedAction
rg -l "'use server'" --glob '*.ts' | xargs rg --files-without-match 'authedAction'

Then triage every hit. The public sign-up action is a legitimate exception: it can’t require a logged-in user because it creates one, so it runs through a different wrapper with its own enumeration discipline. Document it and move on. Everything else the grep surfaces is a finding, an action with no gate, and the fix is to migrate it onto authedAction. That distinction, legitimate exception → document, the rest → migrate, is the triage rule for every seam, and “Server Actions that skipped the wrapper” is the first finding category the audit project will look for.

That’s the template: four questions, same order, every seam. The remaining five are the same two moves landing on a different artifact, so we move faster.

Seam 2: authedRoute, the route-handler boundary

Section titled “Seam 2: authedRoute, the route-handler boundary”

Seam #2 is seam #1’s twin: same shape, different door. Where the action wrapper produces a Result for your own UI to read, the route wrapper produces an HTTP Response for some client you don’t control. You saw this symmetry last lesson; here we put it to use.

Where it lives. authedRoute(role, schema, fn) wraps the handlers in your route.ts files exactly the way authedAction wraps actions. The only structural difference is the framework artifact at the end: a Response, not a Result.

Where fail-closed lands. The gates are the same as seam #1, but each refusal is an HTTP status code instead of a Result branch. The route conventions pin down an enforced table, and it’s worth seeing as a unit, because the table is the part that’s specific to this seam. Everything else here you already know.

route conventions — enforced status table
failure class status reason
───────────────────── ────── ─────────────────────────────
parse failure 422 validation
no session 401 no identity
role too low 403 forbidden
cross-tenant resource 404 not the owner — indistinguishable from "doesn't exist"
business Result.err 4xx matching status via problemFrom()
unexpected throw 500 server bug

One row in that table earns a second look: a cross-tenant resource, a logged-in user from org A asking for org B’s invoice, returns 404, not 403. A 403 says “this exists and you may not have it,” which confirms the resource exists; a 404 says “there’s nothing here to talk about.” Returning the same not-found shape whether the row is missing or simply isn’t yours denies the attacker the one bit they were fishing for. You met this 404-over-403 move last lesson; it’s the same instinct, applied at the route door.

Where message-split lands. The wrapper writes an RFC 9457 body through a problem() helper: { type, title, status, detail, fieldErrors? }. title is operator-honest but still user-safe, detail is the sentence the user reads, and fieldErrors is the same flat Record<string, string[]> shape flattenError produces on the action side. The operator log captures route, method, and headers, minus authorization and cookie, which never belong in a log, plus the parsed input, the ctx, and the cause chain.

The piece that ties seams #1 and #2 together is problemFrom. One business function can be called from both doors: authedAction runs it and turns its error into a Result, while authedRoute runs the same function and problemFrom(result.error) turns that same error into Problem Details. The business logic returns one error; the fork to a user-facing artifact happens at whichever wrapper it flowed through, and it lands correctly at each.

Audit step. This is the mirror of seam #1: a route.ts that exports an HTTP method but never imports the wrapper.

Terminal window
# route.ts files that export a handler but never route through authedRoute
rg -l 'export (async function|const) (GET|POST|PUT|PATCH|DELETE)' --glob '**/route.ts' \
| xargs rg --files-without-match 'authedRoute'

Triage the same way. Webhook receivers (that’s seam #4, a different wrapper with its own signature verify) and public auth callbacks are legitimate exceptions; document them. The rest migrate.

Seam 3: page and layout requireOrgUser(), the Server Component boundary

Section titled “Seam 3: page and layout requireOrgUser(), the Server Component boundary”

Seam #3 has the least new mechanics and the most interesting why. The mechanics are simple: protected Server Components and layouts call requireOrgUser() near the top of the segment, and there’s no wrapper object, since the helper itself is the seam. The why is defense in depth, and it’s worth slowing down for, because it’s the hard-won insight this seam exists to teach.

Where it lives. A protected page.tsx or layout.tsx calls requireOrgUser() as one of its first lines.

Where fail-closed lands. The helper throws on no session or no active org, and the framework’s nearest error.tsx catches the throw and renders the fallback. Fail-closed here is implicit in the throw: there’s no try/catch you write, you just let it fly and let the boundary catch it. The user gets the boundary, never the page body.

Where message-split lands. Almost invisibly, and that’s the point. error.tsx renders generic copy (seam #6 owns that detail), and Sentry capture happens inside the boundary’s effect. The user never sees the underlying message because the framework redacts it before the boundary renders. The split here is structural: you get it for free from the architecture rather than writing it.

So if the gate already runs at the perimeter, where the proxy bounces signed-out users before the page even loads, why re-check it inside the component? Because the two checks are not the same check, and only one of them actually authorizes.

Two rings; only the inner one authorizes.

The outer ring is proxy.ts . It does one cheap thing: it checks that a session cookie is present and bounces signed-out users to /sign-in. What it does not do is authorize. It never asks whether this user still belongs to this org or whether their role is high enough. The inner ring is requireOrgUser() running inside the component, which re-checks against the database and catches exactly the cases the proxy structurally cannot: the user who lost org membership while the page sat open in a tab, the active org that switched in another tab, the session that expired in the gap between the proxy waving the request through and the component rendering. Both rings run on every protected page, but the inner one is where real authorization happens. A page that trusts the proxy alone has a hole, because a present cookie is not a current membership.

Audit step. Find protected Server Components and layouts that read tenant-scoped data without calling requireOrgUser() (or its equivalent) at the top. Each hit is a page leaning only on the outer ring, with a present cookie and an unverified membership, and that’s a finding.

Seam #4 changes who the “user” is. Every seam so far served a browser; this one serves a machine, such as Stripe or Resend, and that reframing is the conceptual hook. It’s also the densest seam: a request walks through several gated layers in a fixed order, and the order is load-bearing. Get it wrong and you’ve built the canonical webhook bug.

A note before we walk it. The full receiver gets built later, in the billing and email work, and the contracts there are fixed: verify on the raw body before you parse, compare the signature in constant time, dedup through a processed_events(provider, event_id) ledger, and map each failure to a specific status. We treat those as canonical here and audit against them; this lesson points at the shape rather than inventing it.

Where it lives. Each receiver lives in app/api/webhooks/*, one for Stripe and one for Resend. These deliberately do not use authedRoute. Their auth model isn’t “prove a logged-in user”; it’s “prove this request actually came from the provider.” So the verify-then-dedup pattern is their wrapper, and it’s a different wrapper because it answers a different question.

Where fail-closed lands. In every layer, in order. The best way to internalize this is to place the steps yourself, because the bug is a wrong ordering.

Order the layers of a webhook receiver. Two orderings are load-bearing: verify must come before any parse, and the dedup INSERT must come before the business work. Drag the items into the correct order, then press Check.

app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
// the five layers below run in a fixed order
}
Read the raw request body (no JSON parse yet)
Verify the signature against the raw body, in constant time
INSERT into the processed_events ledger (ON CONFLICT DO NOTHING)
Run the business work inside the transaction
Respond with the status code for the outcome

Walk those layers as refusals. If the raw-body read fails, the receiver answers 400. If the signature header won’t parse, it answers 400 and logs the malformed header. If the constant-time HMAC compare fails, it answers 401, and because the compare is constant-time the comparison itself leaks no timing. If the HMAC library throws, it answers 500; the provider will retry, which is still a refusal of this attempt. Then the interesting one: if the INSERT ... ON CONFLICT DO NOTHING reports the event was already processed, the receiver answers 200. That 200 is not a refusal. It’s idempotent success, the one outcome here that looks like a refusal but isn’t, so keep it in mind. Finally, if the business work throws inside the transaction, the receiver answers 500 and the provider retries; on the retry, the dedup constraint catches the now-duplicate event so the work never runs twice.

That last step is the whole reason the order matters, and it makes the paired-primitives idea from the first lesson concrete. The receiver fails closed aggressively: it answers a 500 and hangs up the moment anything is uncertain. That is only safe because the dedup ledger makes the provider’s retry idempotent. Refuse aggressively, retry safely: the two compose only because the ledger sits before the business work.

Where message-split lands. The “user” is a machine, so the user-facing artifact is mostly the status code, plus a minimal Problem Details body of just { type, title, status }. The operator record, by contrast, carries everything: the parsed Stripe-Signature or Svix headers, the timestamp delta, the event id and type, the resolved tenant, and the cause chain on a throw. It’s the same split as every other seam, capturing richly for the operator and saying almost nothing to the caller, with a machine as the audience.

Audit step. Visit each receiver and confirm the shape: raw body read before any JSON parse, constant-time signature compare, dedup INSERT before the business work, business work inside the transaction with the dedup, and status codes matching the failure class. The Resend bounce-and-complaint receiver is the same shape with a Svix SHA-256 verify and a five-minute replay window. Three findings to hunt for, all real bugs people ship:

  • A receiver that JSON-parses before it verifies. It’s processing an unauthenticated payload, so it accepts forged events. This is the canonical webhook bug, and it’s exactly the misordering the exercise drilled.
  • A receiver that catches and 200s on a verification failure: fail-open dressed up as “don’t make the provider retry.”
  • A receiver that echoes the full provider payload in its response body, a leak the provider never asked for.

Seam 5: the rate-limiter call, the one documented fail-open carve-out

Section titled “Seam 5: the rate-limiter call, the one documented fail-open carve-out”

Seam #5 is the lesson’s exception. The rule of this whole chapter is fail closed by default, and this is the single, deliberate place that inverts it. You met safeLimit and the reasoning in the first lesson; here it earns its place as a seam, with its own audit step.

Where it lives. Every rate-limit decision in the app goes through safeLimit(limiter, key) in lib/rate-limit.ts. The carve-out exists in exactly one place, and that is precisely what makes a fail-open defensible rather than a scattered bug. A fail-open you can point at, with a written reason, in one helper, is an architectural decision. The same behavior copy-pasted across call sites is a vulnerability.

Where fail-closed lands, and why it deliberately doesn’t. Here is the inversion. safeLimit wraps the limit() call, catches a throw, logs it at error level, and returns { success: true }, meaning that when Redis is unreachable, the request is let through.

lib/rate-limit.ts
try {
return await limiter.limit(key);
} catch (error) {
logger.error({ event: 'rate_limit_unavailable', limiter, key, err: error });
return { success: true };
}

The reasoning, restated from the first lesson: a Redis outage that locks every user out of their own account is a far worse incident than a brief window where the abuse limiter is down. So availability failures fail open, on purpose. But read the carve-out precisely, because the boundary is narrow. Fail-open is the policy only for Redis being unavailable. Actual quota exhaustion, where the user really did hit the limit and Redis answered cleanly with “denied,” still fails closed and returns the 429. The carve-out is “I couldn’t reach the limiter,” not “the limiter said no.” And even the availability policy isn’t universal: a limiter guarding admin actions or a billing path the customer can’t retry might flip the default back to fail-closed, where the cost of abuse outweighs the cost of a lockout. That policy lives in the helper too, never at the call sites.

Where message-split lands. The user-facing 429 body is the same opaque message no matter which gate tripped. The IP limiter and the per-email limiter both produce the identical rateLimited(...)err('rate_limited', 'Too many attempts. Please try again later.'). That sameness is deliberate: a message that said “your email is being limited” would itself be a signal, because it would confirm the email belongs to an account. The route twin, rateLimitedResponse(result), returns the 429. The structured log carries the full diagnosis for the operator: which gate, which key, remaining, and reset.

Audit step. Grep for .limit( calls that don’t go through safeLimit. A direct limiter.limit() sitting in a handler has bypassed the documented policy: it’ll fail however the limiter happens to throw, with no logged event and no deliberate decision behind it, and that’s a finding. The carve-out is auditable precisely because it’s centralized, with one helper to read for the policy and one pattern to grep for the bypass.

The judgment this seam demands is the most over-generalized idea in the chapter, so test it on a fresh case rather than the auth example you’ve already seen.

A teammate adds an admin-only bulk-delete endpoint, rate-limits it, and asks how its limiter should behave when Redis is unreachable. They point at safeLimit returning { success: true } on the auth path as the precedent to copy. What’s the right call for this limiter?

Don’t copy the auth-path default here — a limiter guarding mass deletion is exactly the kind of path that flips back to fail-closed, since letting abuse through during the outage is worse than briefly blocking deletes; encode that as a policy inside the helper.
Copy the auth-path default — once one limiter fails open, every limiter should fail open too, so the app behaves the same way everywhere.
Fail open, but expire the allowance: let the bulk delete through only if the admin re-issues the request inside a five-minute window.
Keep it fail-closed, and do it by wrapping the endpoint’s limit() call in its own try/catch that answers a 503 when Redis throws.

Seam 6: error.tsx and global-error.tsx, the page boundary

Section titled “Seam 6: error.tsx and global-error.tsx, the page boundary”

The last seam is the catcher. Seams #1 and #3 throw into this one: every uncaught throw from a Server Action gate or a page check lands here. You saw the canonical error.tsx last lesson; here it’s the sixth seam, with its own audit step.

Where it lives. An error.tsx sits at any route segment that renders sensitive data, and a global-error.tsx sits at the root as the fallback for the errors error.tsx can’t catch, most importantly the root layout itself throwing. Both are 'use client' components, and they’re owned by the framework’s boundary mechanism rather than called by your code.

Where fail-closed lands. The framework catches the thrown error, and in production builds Next.js redacts error.message and ships a stable digest in its place. The fail-closed reading is the same as everywhere else: the user never gets the resource, only the boundary. global-error.tsx is the last line of defense, so when it fires you assume nothing above it survived, which is why it ships its own <html> and <body>.

Where message-split lands. The boundary renders generic copy only. The digest is the single piece of operator-side detail the user is allowed to see: it’s opaque, so it leaks nothing, but it’s joinable, so the user quotes it to support and the operator looks it up in Sentry. Capture happens in a useEffect. The detail that matters most here is an absence: error.message is never read in the JSX. That blank space is the design.

app/(app)/dashboard/error.tsx
{error.digest && <p>Reference: {error.digest}</p>}
{/* never render error.message — it leaks server internals in prod */}

One more detail belongs to this seam: retry uses unstable_retry() (Next.js 16.2+), not the bare reset() you might reach for. The reason is mechanical. unstable_retry runs router.refresh() and reset() together inside a transition, so it can recover a render that failed during a data fetch, which is most of them. A bare reset() re-runs the render but not the fetch, so it just fails again. Reach for unstable_retry.

Audit step. Confirm four things: every sensitive segment has an error.tsx, the root has a global-error.tsx, none of them render error.message, and each calls Sentry capture. Capture can happen automatically via the integration or with an explicit captureException, and the explicit call is the anchor you want in global-error.tsx especially, since by the time it fires the render has already failed once. The findings to look for: a segment with no boundary (it falls through to a parent, usually fine but worth confirming); a boundary that surfaces the message; a global-error.tsx with no Sentry capture; or the design-level leak, an error.tsx with a “Show details” toggle, which is a leak vector by intent. The grep is direct.

Terminal window
# any boundary that surfaces the raw message, or a "show details" toggle
rg 'error\.message' --glob '**/{error,global-error}.tsx'
rg -i 'show details' --glob '**/{error,global-error}.tsx'

There’s one recurring confusion to clear at this seam. notFound() and redirect() are not errors; they’re framework control-flow primitives that happen to be implemented as throws. error.tsx does not catch them. not-found.tsx and the framework’s redirect handler do. Both rules still apply, which is why the distinction matters and doesn’t break anything: fail-closed holds because a thrown notFound() aborts the render and the user doesn’t get the resource, and message-split holds because the not-found page renders generic copy with no leak of which id was requested. The first lesson’s framing is the one to keep: a thrown Error is fail-closed plumbing the boundary catches, while a thrown notFound() is framework control flow the rule passes through. The bug to watch for is a boundary that catches and swallows a notFound(), which is a category error. Re-throw it.

The code channel ties the six seams together

Section titled “The code channel ties the six seams together”

After six seams, it’s easy to read them as six disconnected checklists. They aren’t. A single thread runs through all of them, and it’s worth one short section to name it.

Every error returned through any seam carries a stable code: one of seven canonical values, validation | conflict | not_found | unauthorized | forbidden | rate_limited | internal, or, for the route and webhook seams, the RFC 9457 type that mirrors it. That code is the contract between layers. The action wrapper, the route wrapper, the webhook receiver, and the error boundary all reach into the same enumerated set; none of them invents its own vocabulary.

Here’s why that’s worth enforcing rather than leaving to taste. The analytics layer groups error events by code. So a single seam that quietly returns a free-form code: 'something_failed' doesn’t just look untidy: it fragments the dashboard, splitting what should be one bar into a long tail of one-off strings nobody can chart. The six seams only sum to one coherent error picture if they share the enumeration. A new code is a deliberate addition to the set, not a string you type at a call site.

Hold on to the separation the previous lesson drew, because the code is exactly where people blur it. The code is the machine identifier, the thing analytics groups on, and userMessage is the human string, the thing you render. Never render the code, and never group on the userMessage. (The consumer of the operator side gets built later: analytics through PostHog, behind the consent gate, in the observability work near the end of the course. This lesson just establishes that the code is the shared spine they all read.)

Here is the rule the whole lesson exists to deliver, stated plainly: a new feature’s error paths must fit one of the six shapes. Server Action, route handler, page check, webhook receiver, rate limiter, page boundary. When you add a feature and ask “where do its errors go,” the answer is one of those six, and the two commitments come along for free because the seam already owns them.

When a genuinely new shape shows up, such as a hand-rolled internal API client doing raw fetch or a receiver for some custom protocol, the move is not to let it drift in as one more un-audited path. The move is to add a seventh seam to the catalog, on purpose: give it its own wrapper, document where its fail-closed lands and where its message-split lands, and write down the grep that finds its bypasses. The seam catalog is the architectural error surface of the app. Growing it is a deliberate decision; growing it by accident is how the surface rots.

Three habits protect the catalog, and they’re the ones to watch for on review:

  • A seam “almost like authedAction but with one extra parameter” must extend the wrapper, never fork it. A parallel wrapper drifts, and worse, the grep for the original never finds the copy, so “one place to lint” silently becomes two places, one of which nobody is watching.
  • A page check inside a Client Component bypasses the Server Component perimeter entirely. The client can’t be trusted to gate anything; authorization belongs on the server seam.
  • A stray console.log(error) leaks into a production branch. Use the structured logger’s debug level instead. The full logging discipline lands in the observability work later in the course, but the reflex starts now.

Now consolidate. First, a fast sort: read each finding and decide which of the two rules it breaks.

Each chip is a finding from an audit pass. Sort it by which of the two commitments it breaks — does the gate let something through that should have been refused, or does the wrong audience see the wrong thing? Drag each item into the bucket it belongs to, then press Check.

Fail-closed violation A gate fails (or is absent) but the request proceeds anyway
Message-split / leak violation The user sees something they shouldn't, or the operator side leaks
A 'use server' action that never imports authedAction
JSON-parsing the webhook body before the HMAC compare
A direct limiter.limit() call in a handler, not via safeLimit
A webhook receiver that catches a bad signature and responds 200
A protected page that reads tenant data but never calls requireOrgUser()
A requireRole that returns false when its membership query throws
A 429 body that says “your email is being rate-limited”
An error.tsx that renders {error.message}
A cross-tenant /invoices/[id] returning 403 instead of 404
A duplicate-key DB error surfaced to the user as invoices_org_id_slug_key
An error.tsx with a “Show details” toggle revealing the stack

Then the real test of the map — can you reconstruct a seam from memory? Take one seam and write its four facts.

Your assigned seam is the route-handler boundary (seam #2). From memory — without scrolling back up — write its four facts in your own words:

  1. the wrapper (and the files) that owns it,
  2. where fail-closed lands,
  3. where message-split lands, and
  4. the grep that finds a handler which skipped the seam.

This map isn’t the end of the audit; it’s the lens the rest of these chapters apply. The next chapter, the security baseline, walks these same seams for a different class of problem: headers, secrets, GDPR, and dependency hygiene. The chapter after it is the project that runs the full audit, both error discipline and the security baseline, against a seeded codebase with planted findings, and the six-seam map is the checklist it hands you. Further out, the observability chapter wires the Sentry capture and the structured logger that consume the operator side of every split, and the continuous-integration chapter turns these grep patterns into lint rules that catch a seam bypass at PR time instead of at audit time. The manual grep is where it starts; automation comes later.