Skip to content
Chapter 80Lesson 3

The six error seams

The six boundaries where the fail-closed and two-message rules land, each with the wrapper that owns it and the grep that catches a bypass.

You have two error rules. Fail closed: 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: 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’s test, does this fail closed, and is what the user sees safe to read aloud on a support call?, judges one path at a time; it can’t tell you where all the paths are. For that you need every place the two rules have to land.

There are exactly six: wrappers, helpers, and boundaries, each owning both rules, each 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.

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. The lesson visits them one at a time, asking the same four questions: where the seam lives, where fail-closed lands, where message-split lands, and the audit step — what to grep for to find the path that skipped the seam, and how to triage the hits. Answer the four once 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 here because it’s the seam you know best: the canonical Server Action wrapper from the organizations work. The four answers here are the template for the other five.

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, so the gate logic lives in exactly one place: one place to lint, one name to grep for.

Where fail-closed lands. Every exit on a failed gate is a refusal, and 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 to the framework’s nearest error.tsx, so the user gets the boundary, not the resource. If 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, the wrapper’s single try/catch returns it as an internal Result. Four mechanisms, one outcome: refuse.

Where message-split lands. In that same try/catch, which runs the two moves from the previous lesson in order: write the operator record, then produce the user side.

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, never the userMessage. The wrapper applies the split once, for every action, so you never apply it at each one.

Audit step. The seam owns both rules only for the code that 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 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.

Seam 2: authedRoute, the route-handler boundary

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

Seam #2 is seam #1’s twin: same gates, different door. The action wrapper returns a Result for your own UI; the route wrapper returns an HTTP Response for a client you don’t control.

Where it lives. authedRoute(role, schema, fn) wraps the handlers in your route.ts files just as authedAction wraps actions, ending in a Response instead of a Result.

Where fail-closed lands. Same gates as seam #1, but each refusal is now an HTTP status code rather than a Result branch:

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 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 (“you may not have this”) confirms the resource exists; a 404 says there is 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.

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 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, plus the parsed input, the ctx, and the cause chain.

problemFrom is what ties seams #1 and #2 together. One business function returns one error; authedAction turns it into a Result, while authedRoute runs problemFrom(result.error) to turn that same error into Problem Details. The fork to a user-facing artifact happens at whichever wrapper the error flowed through.

Audit step. 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 as before. Webhook receivers (seam #4, with its own signature verify) and public auth callbacks are legitimate exceptions; document them, and migrate the rest.

Seam 3: requireOrgUser(), the Server Component boundary

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

A protected Server Component or layout calls requireOrgUser() near the top of the segment. There is no wrapper object; the helper itself is the seam, and what it teaches is defense in depth.

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 nearest error.tsx catches the throw and renders the fallback. You write no try/catch: you let the throw reach the boundary, so the user gets the boundary, never the page body.

Where message-split lands. Structurally, for free. error.tsx renders generic copy (seam #6 owns that detail) and Sentry capture happens inside the boundary’s effect, and the framework redacts the underlying message before the boundary renders.

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

Two rings; only the inner one authorizes.

The outer ring is proxy.ts . It does one cheap thing: confirm a session cookie is present and bounce signed-out users to /sign-in. It never asks whether this user still belongs to this org or whether their role is high enough, so it does not authorize. The inner ring, requireOrgUser(), re-checks against the database and catches the three cases above that the proxy structurally cannot. Both rings run on every protected page, but only the inner one authorizes, 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 leans on the outer ring alone, a present cookie and an unverified membership, and that’s a finding.

Seam #4 changes who the “user” is. Every other seam serves a browser; this one serves a machine such as Stripe or Resend. It’s also the densest: the request walks through several gated layers in a fixed, load-bearing order.

The full receiver gets built later, in the billing and email work, against fixed contracts: 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.

Where it lives. Each receiver lives in app/api/webhooks/*, one for Stripe and one for Resend. They deliberately skip authedRoute: their question isn’t “prove a logged-in user” but “prove this request came from the provider.” A different question gets a different wrapper: verify-then-dedup.

Where fail-closed lands. In every layer, in order. The bug is a wrong ordering, so place the steps yourself.

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

Each layer is a refusal with its own status. A failed raw-body read answers 400; a signature header that won’t parse answers 400 and logs the malformed header. A failed constant-time HMAC compare answers 401, and because the compare is constant-time it leaks no timing. If the HMAC library throws, it answers 500; the provider retries, which still refuses this attempt. The one exception is the dedup hit: when 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. 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 why the order matters. The receiver fails closed aggressively, answering 500 and hanging up the moment anything is uncertain, and 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 { type, title, status }. The operator record 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.

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, the exact 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 documented fail-open carve-out

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

Fail-closed is this chapter’s default, and this seam is the single deliberate place that inverts it.

Where it lives. Every rate-limit decision goes through safeLimit(limiter, key) in lib/rate-limit.ts, so the carve-out exists in exactly one place. That is what makes the fail-open defensible: a documented carve-out in one helper is an architectural decision, while the same behavior copy-pasted across call sites is a vulnerability.

Where fail-closed lands, and why it deliberately doesn’t. safeLimit wraps the limit() call, catches a throw, logs it, and returns { success: true }, so an unreachable Redis lets the request 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 };
}

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. The boundary is narrow, though: fail-open is the policy only for Redis being unavailable. Actual quota exhaustion, where the user 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.” Even the availability policy isn’t universal: a limiter guarding admin actions or a billing path the customer can’t retry might flip 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.'). A message that said “your email is being limited” would itself be a signal, confirming the email belongs to an account. The route twin, rateLimitedResponse(result), returns the 429, and 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() in a handler has bypassed the documented policy: it fails however the limiter happens to throw, with no logged event and no deliberate decision behind it, and that’s a finding. Centralizing the carve-out is what makes it auditable: one helper to read for the policy, one pattern to grep for the bypass.

The fail-open default 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: every uncaught throw from a Server Action gate (#1) or a page check (#3) lands here.

Where it lives. An error.tsx sits at any route segment that renders sensitive data; a global-error.tsx sits at the root for what error.tsx can’t catch, above all the root layout itself throwing. Both are 'use client' components, owned by the framework’s boundary mechanism rather than called by your code.

Where fail-closed lands. The framework catches the throw, and in production builds Next.js redacts error.message and ships a stable digest in its place. As everywhere, the user gets the boundary, never the resource. 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 and captures detail in a useEffect. The digest is the one operator-side detail the user may see: opaque, so it leaks nothing, but joinable, so the user quotes it to support and the operator looks it up in Sentry. What matters most is an absence: error.message is never read in the JSX.

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

Retry uses unstable_retry() (Next.js 16.2+), not the bare reset() you might reach for. unstable_retry runs router.refresh() and reset() together in 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.

Audit step. Confirm four things: every sensitive segment has an error.tsx, the root has a global-error.tsx, none render error.message, and each calls Sentry capture. Capture can run through the integration automatically or with an explicit captureException; 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, a leak vector by intent.

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'

notFound() and redirect() are not errors: they’re framework control-flow primitives implemented as throws, and error.tsx does not catch them, not-found.tsx and the redirect handler do. Both rules still hold: fail-closed, because a thrown notFound() aborts the render and the user doesn’t get the resource; message-split, because the not-found page renders generic copy without leaking which id was requested. The bug to watch for is a boundary that catches and swallows a notFound(), a category error. Re-throw it.

Every error from any seam carries a stable code from seven canonical values, validation | conflict | not_found | unauthorized | forbidden | rate_limited | internal, or, on the route and webhook seams, the RFC 9457 type that mirrors it. That code is the contract between layers: every seam reaches into the same enumerated set rather than inventing its own.

The analytics layer groups error events by code, so a seam that quietly returns a free-form code: 'something_failed' splits one bar into a long tail of one-off strings nobody can chart. The six seams sum to one error picture only if they share the enumeration, so a new code is a deliberate addition to the set, not a string you type at a call site.

Here the previous lesson’s split is easiest to blur: code is the machine identifier analytics groups on, userMessage is the human string you render. Never render the code, and never group on the userMessage.

When you add a feature and ask where its errors go, the answer is one of six shapes: Server Action, route handler, page check, webhook receiver, rate limiter, page boundary. 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 API client doing raw fetch or a receiver for a custom protocol, don’t let it drift in as one more un-audited path. Add a seventh seam on purpose: give it a wrapper, document where its fail-closed and message-split land, and write the grep that finds its bypasses. The catalog is the app’s architectural error surface, and growing it by accident is how that surface rots.

Three habits protect the catalog, and they are 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 the grep for the original never finds the copy, so “one place to lint” silently becomes two, one of which nobody watches.
  • 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.

Now consolidate: 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

Now the real test of the map: 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.

These six are the lens the next chapters reuse, for the security baseline, the audit project, observability, and CI lint rules.