SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Jun 12, 2026·Security·SecureStartKit Team

Next.js Errors That Fail Open: The OWASP A10 Fix [2026]

A caught redirect() or a swallowed auth check makes Next.js fail open. Where error handling grants access by accident, and the fail-closed fix.

Summarize with AI

On this page

  • Table of contents
  • What is OWASP A10, Mishandling of Exceptional Conditions?
  • Why does a redirect() inside try/catch fail open?
  • How does a swallowed auth check hand over admin access?
  • When is an empty catch block safe, and when is it a bug?
  • How do you fail closed across a half-finished mutation?
  • What does a fail-closed Server Action actually look like?
  • Make "deny by default" the failure mode

On this page

  • Table of contents
  • What is OWASP A10, Mishandling of Exceptional Conditions?
  • Why does a redirect() inside try/catch fail open?
  • How does a swallowed auth check hand over admin access?
  • When is an empty catch block safe, and when is it a bug?
  • How do you fail closed across a half-finished mutation?
  • What does a fail-closed Server Action actually look like?
  • Make "deny by default" the failure mode

Next.js error handling fails open when a check that was supposed to stop a request doesn't stop it. A redirect() swallowed by a try/catch, an authorization throw caught and logged, a mutation that dies halfway and leaves the database in a state nobody planned: each one lets execution continue past the point where it should have denied. OWASP named this class in its 2025 edition.

The category is A10:2025 Mishandling of Exceptional Conditions, and the full OWASP Top 10 map for Next.js and Supabase places it last on the list because it is the one that turns latent bugs into incidents. The official definition is plain: mishandling exceptional conditions "happens when programs fail to prevent, detect, and respond to unusual and unpredictable situations, which leads to crashes, unexpected behavior, and sometimes vulnerabilities" [1].

This post is about the security half of error handling, not the observability half. Wiring up error.tsx, the production digest, and Sentry so you can actually see what broke is its own job, covered in the error.tsx and Sentry setup guide. Here the question is narrower and sharper: when something throws, does your app deny, or does it quietly continue? Everything below assumes a Server Action or Server Component that performs a privileged operation, and a failure that lands somewhere you didn't expect.

TL;DR:

  • Fail closed means: when anything goes wrong, deny. OWASP A10:2025 maps directly to CWE-636 Not Failing Securely ('Failing Open'), where a failure makes the system "fall back to a state that is less secure" and "intended access restrictions can be bypassed" [2].
  • redirect() throws on purpose. Next.js documents that redirect "throws an error so it should be called outside the try block" [3]. A try/catch around your auth gate swallows the redirect and runs the rest of the action.
  • Not every empty catch is a bug. A swallowed cookie write is fine. A swallowed authorization check is a bypass. The difference is whether an access decision depends on the thing that failed.
  • Model expected errors as return values. Next.js says it directly: "avoid using try/catch blocks and throw errors. Instead, model expected errors as return values" [4]. A structured result the caller can't ignore is the fix.

Table of contents

  • What is OWASP A10, Mishandling of Exceptional Conditions?
  • Why does a redirect() inside try/catch fail open?
  • How does a swallowed auth check hand over admin access?
  • When is an empty catch block safe, and when is it a bug?
  • How do you fail closed across a half-finished mutation?
  • What does a fail-closed Server Action actually look like?
  • Make "deny by default" the failure mode

What is OWASP A10, Mishandling of Exceptional Conditions?

A10:2025 is the new OWASP Top 10 category for bugs where errors fail open: a check throws, the throw is mishandled, and the code keeps running as if the check passed. OWASP describes the failure as programs that "fail to prevent, detect, and respond to unusual and unpredictable situations" [1]. It is the category that catches the bugs no other category names.

The category folds in 24 CWEs, but two carry the security weight. CWE-755 Improper Handling of Exceptional Conditions is the broad one: "the product does not handle or incorrectly handles an exceptional condition," and "unhandled errors may have unexpected results" [5]. The sharper one is CWE-636, "Not Failing Securely ('Failing Open')," where on error the system's "design requires it to fall back to a state that is less secure than other options that are available, such as ... using the most permissive access control restrictions" [2]. The consequence CWE-636 names is the one you care about: "intended access restrictions can be bypassed, which is often contradictory to what the product's administrator expects" [2].

OWASP gives the design rule in one sentence. If you are partway through a transaction, "it is extremely important that you roll back every part of the transaction and start again (also known as failing closed). Attempting to recover a transaction part way through is often where we create unrecoverable mistakes" [1]. Fail closed is the default state you want: on any unexpected condition, deny access and undo partial work, rather than guess your way forward. The rest of this post is five concrete places a Next.js app drifts away from that default, and how each one reads in code.

Why does a redirect() inside try/catch fail open?

Because redirect() works by throwing. Next.js documents it precisely: "Invoking the redirect() function throws a NEXT_REDIRECT error and terminates rendering of the route segment in which it was thrown," and "redirect should be called outside the try block when using try/catch statements" [3]. When your auth gate is a redirect() inside a try, the surrounding catch intercepts the very throw that was supposed to stop the request.

Here is the trap in a Server Action. The intent is obvious: unauthenticated callers get bounced to login before anything privileged happens.

'use server'

import { getUser, createAdminClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export async function deleteAccount(targetId: string) {
  let actor
  try {
    actor = await getUser()
    if (!actor) {
      redirect('/login') // throws NEXT_REDIRECT, meant to stop here
    }
  } catch (err) {
    // NEXT_REDIRECT lands here and is swallowed as if it were a normal error
    console.error('auth check failed', err)
  }

  // Runs even when actor is undefined and the redirect never happened
  const admin = createAdminClient()
  await admin.from('profiles').delete().eq('id', targetId)
}

Read the control flow carefully. When actor is null, redirect('/login') throws NEXT_REDIRECT. That throw should unwind the whole action. Instead the catch grabs it, logs it next to ordinary errors, and execution falls out of the try/catch and into the createAdminClient() call. The destructive delete now runs with no authenticated actor. This is CWE-636 in three lines: the failure path is more permissive than the success path [2].

The notFound() function has the same shape. It throws an internal control-flow signal, so a try/catch wrapped around notFound() swallows the 404 and lets the handler keep serving a resource it already decided should not exist. The fix is structural, not cosmetic: keep redirect() and notFound() out of try blocks entirely. Next.js makes this ergonomic by typing redirect to return never, so the compiler already knows nothing runs after it [3]. Put the gate first, let it throw, and never catch navigation control flow.

How does a swallowed auth check hand over admin access?

The same way, generalized: any authorization check that signals failure by throwing becomes a no-op the moment a catch swallows the throw without re-denying. The throw was the entire security control. Catch it, log it, continue, and you have converted "deny" into "allow." This is the most dangerous shape of A10 because the thrown guard often lives one function call away from the code it was protecting.

SecureStartKit gates its admin actions with a throwing helper. Every privileged admin call runs requireSuperAdmin() as its first line, and the helper throws when the caller is not an allowlisted super admin:

import { createAdminClient, getUser } from '@/lib/supabase/server'
import config from '@/config'

async function requireSuperAdmin() {
  const user = await getUser()
  if (!user || !config.admin.superAdminEmails.includes(user.email!)) {
    throw new Error('Unauthorized') // the gate: nothing past this runs
  }
  return user
}

export async function getAllUsers(page = 1, limit = 20) {
  await requireSuperAdmin() // line 1, before any data access
  const admin = createAdminClient()
  // ... read every profile in the system
}

This is fail-closed by construction. The throw propagates straight out of getAllUsers, no privileged query runs, and Next.js renders the nearest error boundary instead. It only stays fail-closed under one condition: no caller wraps requireSuperAdmin() in a try/catch that swallows the error. The moment someone wraps that call in a try/catch that logs and continues, the gate is gone and the function reads every profile in the database for an unauthorized user. The throw is load-bearing, and its safety depends on staying uncaught.

The subtler variant uses a value instead of a throw. getUser() returns the user or null, but the underlying supabase.auth.getUser() makes a network call, and a network call can throw. Consider this seemingly careful handling:

let user
try {
  user = await getUser()
} catch {
  user = null // treat an auth failure as "no user"
}

On the surface it looks reasonable. But now a transient Supabase outage produces the same null as a genuine logout, and if any downstream code treats null as "public, allow read-only," your access decision is being driven by network weather. Fail closed means the unknown state is the denied state. When you cannot prove the caller is authorized, deny, and let the request retry. Validation throwing should be handled the same way, which is why input validation with Zod belongs before the privileged work, not tangled inside the same catch that handles infrastructure failures.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

When is an empty catch block safe, and when is it a bug?

An empty catch is safe when no access decision depends on the operation that failed, and unsafe when one does. The test is not "is this catch empty," it is "if this try body throws and we continue, does the user end up with access or data they should not have?" If yes, the catch is a bypass. If no, it can be a legitimate recovery. Senior review judges catches by what depends on them, not by their shape.

This codebase ships a deliberately empty catch, and it is correct. The server-side Supabase client writes refreshed auth cookies, but a Server Component is not allowed to set cookies, so the write throws there. The code swallows it on purpose:

setAll(cookiesToSet) {
  try {
    cookiesToSet.forEach(({ name, value, options }) => {
      cookieStore.set(name, value, options)
    })
  } catch {
    // setAll can fail in Server Components - that's fine,
    // the proxy/middleware will handle cookie refreshes
  }
}

This is safe for three specific reasons, and they are worth naming because they form a checklist. First, the failure is non-security: a deferred cookie write does not grant anyone access. Second, recovery exists elsewhere: the proxy layer refreshes the cookie on the next request, so nothing is permanently lost. Third, no branch reads the result of this write to make an authorization decision. Flip any one of those and the same empty catch becomes a bug.

Contrast it with the auth swallow from the previous section. There, the failure is security-relevant (it gates a delete), no other layer re-checks authorization, and the code immediately past the catch acts on the assumption that the check passed. Same syntax, opposite verdict. When you review a catch, trace one question through it: does failing here, silently, change who can do what? A useful habit is to make every security-relevant catch re-deny explicitly, with an error return or a re-throw, so the "continue anyway" path simply does not exist in the code.

How do you fail closed across a half-finished mutation?

You make the failure atomic: either the whole mutation commits or none of it does, so a throw in the middle cannot leave a privileged half-state. OWASP is explicit that partial recovery is where the real damage happens. "Roll back every part of the transaction and start again ... attempting to recover a transaction part way through is often where we create unrecoverable mistakes" [1]. A mutation that writes three rows and throws after the second is the database equivalent of failing open.

The classic shape is a multi-step write in a Server Action: insert a record, flip an access flag, send a delivery email. If the action throws after the access flag is set but before the email sends, the user has access they never got told about, or worse, access tied to a payment that did not finish settling. The error boundary renders a friendly "something went wrong," the user retries, and now you have two records and a duplicated grant. The friendly fallback masked a state corruption.

There are three durable ways to keep this fail-closed, in rough order of preference:

  • Push multi-row changes into a single database operation. A Postgres function (RPC) or a single statement either fully applies or fully rolls back. The application never observes a half-state because Postgres never exposes one.
  • Order writes so the irreversible, externally-visible step is last and idempotent. Grant access only after the payment row is confirmed written, and key the grant on a unique identifier so a retry updates rather than duplicates. The Stripe webhook idempotency pattern covers the insert-before-process discipline that makes retries safe.
  • Treat fire-and-forget as a deferred risk, not a free one. An unawaited sendEmail() or background cleanup that throws produces an unhandled rejection your action never sees. If the cleanup matters for security (revoking an old token, deleting an orphaned file), it is part of the transaction and must be awaited and checked, not fired into the void.

The unifying rule: never let a privileged operation reach a state where "it threw halfway" and "it succeeded" look the same to the next request. If you cannot make the whole thing atomic, make the visible effect the last, smallest, idempotent step, and make everything before it reversible. Then a throw rolls back to a clean denied state instead of a corrupted granted one.

What does a fail-closed Server Action actually look like?

It validates first, authorizes second, performs the privileged work last, and signals expected failures as return values the caller has to read. Next.js states the principle directly: for expected errors, "avoid using try/catch blocks and throw errors. Instead, model expected errors as return values" [4]. The throw is reserved for genuinely unexpected exceptions, which the error boundary then catches.

The template's billing action follows this order. Validation failure returns early, missing auth redirects before the throw-prone try work, and only validated, authorized requests reach the Stripe call:

'use server'

export async function startCheckout(input: CheckoutInput) {
  const parsed = checkoutSchema.safeParse(input)
  if (!parsed.success) {
    return { error: 'Invalid input' } // expected error, returned as a value
  }

  const user = await getUser()
  if (!user) {
    redirect('/login') // outside any try, throws cleanly, terminates the action
  }

  // Only validated, authenticated requests run past this line.
  const admin = createAdminClient()
  // ... create the Stripe session, then redirect to it
}

Three things make this fail-closed. The safeParse early return denies on bad input without throwing. The redirect() sits outside any try, so its NEXT_REDIRECT is never swallowed. And the privileged work is physically below both gates, so there is no path to it that skips them. This is the shape every state-changing Server Action should take, and it is worth testing the failure paths explicitly rather than only the happy path, because the bug you are guarding against only shows up when something throws.

The template returns ad-hoc result objects today, an error object on failure and a success object or a redirect() on completion. That works, but it has a gap: a caller can ignore the returned object entirely and the type system says nothing. The stricter upgrade is a discriminated-union result type that makes "did this fail" impossible to skip:

type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string }

export async function startCheckout(
  input: CheckoutInput
): Promise<ActionResult<{ url: string }>> {
  const parsed = checkoutSchema.safeParse(input)
  if (!parsed.success) {
    return { ok: false, error: 'Invalid input' }
  }
  // ...
  return { ok: true, data: { url: session.url } }
}

With this shape the caller has to narrow on result.ok before it can touch result.data, so "forgot to check whether it failed" becomes a compile error instead of a runtime bypass. It is a refinement of the same fail-closed discipline, not a different one: the early-return guards still do the security work. The union just removes the last place a caller could pretend a failure was a success. The point is the ordering and the explicit denial, whatever the return shape: validate, authorize, then act, and make every failure say no out loud.

Make "deny by default" the failure mode

OWASP A10:2025 is the category that asks one question of every privileged path in your app: when this throws, who can do what? The answer should always be "less than before, not more." Fail open is a redirect() caught by a stray try/catch, an authorization throw logged and ignored, a mutation that dies between writes and leaves a grant standing. Fail closed is the opposite default: validate first, let auth gates throw uncaught, return expected errors as values, and make partial work atomic so a failure rolls back to denied.

None of the fixes are large. They are an ordering convention, a habit of keeping navigation control flow out of try blocks, and a review reflex that asks whether a given catch changes an access decision. The full category map, with every OWASP 2025 entry traced to a Next.js and Supabase failure pattern, lives in the OWASP Top 10 for Next.js and Supabase guide, and the error.tsx and Sentry setup covers making these failures visible once they do happen. If you would rather inherit the validate-authorize-act ordering already wired through every Server Action instead of retrofitting it, that is the architecture the SecureStartKit template ships with by default.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. A10:2025 Mishandling of Exceptional Conditions— owasp.org
  2. CWE-636: Not Failing Securely ('Failing Open')— cwe.mitre.org
  3. redirect | Next.js— nextjs.org
  4. Getting Started: Error Handling | Next.js— nextjs.org
  5. CWE-755: Improper Handling of Exceptional Conditions— cwe.mitre.org

Related Posts

Jun 5, 2026·Security

5 Production Rate-Limit Failure Modes in Next.js [2026]

Five production rate-limit failure modes for Next.js Server Actions: XFF off Vercel, fixed-window burst, distributed IPs, missing await, billing.

May 30, 2026·Security

Supabase Password Reset in Next.js: 5 Failure Modes [2026]

Supabase password reset breaks in 5 ways. Referer leaks, session fixation, token reuse, weak OTP windows, email enumeration. The fix for each.

May 16, 2026·Security

OWASP Top 10:2025 for Next.js + Supabase Apps

OWASP Top 10:2025 mapped to Next.js + Supabase failure modes plus the architectural defenses that prevent each category. With 2026 CVEs.