TLDR: Never use a 302 redirect to "protect" an API route. A redirect is page protection — not a gate. Exempt
/api/*from your middleware, then add the real check per route.
The Setup
I was building a Next.js 16 supply chain dashboard for an ecommerce business, backed by Supabase auth.
Standard stuff: the middleware (we renamed it proxy because Next 16 reserves the name) intercepts every unauthenticated request and redirects to /login.
What I didn't think about: "every request" includes /api/*.
What Broke
The Shopify sync route lives at /api/shopify/sync.
It's called server-to-server — a cron, a background process, nothing browser-shaped.
So when it hit the proxy unauthenticated, instead of running… it got a 302 to the HTML login page.
No error. No crash. Just an HTML document where JSON was expected, parsed silently into garbage, and the sync appeared to "run" while doing exactly nothing.
That's the sneaky part. A 302 doesn't throw. fetch() follows it, gets a 200 on the login page, and your caller has no idea.
What I Chased First
My first instinct was wrong — I suspected the OAuth callback itself.
Added logging to surface exchangeCodeForSession errors, dug into the Supabase callback flow, found nothing.
The auth flow was fine. The problem was never auth.
The problem was that a route meant for a server-to-server caller was getting page-protection meant for a human.
The Fix
One condition in the proxy: skip the session redirect for any path starting with /api.
if (req.nextUrl.pathname.startsWith('/api')) {
return next()
}
Three lines. Fixed immediately.
But here's where it gets interesting — because now I had the opposite problem.
The Real Gate
Once you pull the redirect off /api, those routes are fully exposed.
For authed API routes, the answer is easy: return 401 yourself. Don't rely on the middleware to be your bouncer.
For unauthenticated routes that still hit paid external APIs — Shopify, Anthropic, Gmail — there's a subtler risk. CORS already blocks your browser from reading the response across origins. But it doesn't block the request from being sent. A cross-site attacker can still fire your /api/sync endpoint and burn your Shopify API quota before CORS swallows the response on their end.
The fix I started reaching for on the next project: gate on Sec-Fetch-Site.
if (req.headers.get('sec-fetch-site') === 'cross-site') {
return new Response('Forbidden', { status: 403 })
}
Browser-set, JS-unforgeable, missing on curl (so you don't block your own server-to-server callers). Three lines, per route, exactly where it needs to be.
Why This Matters
A 302 redirect is not auth. It's navigation.
The instant you have a caller that isn't a human with a browser — a cron, a webhook, a server function — your middleware redirect becomes an invisible black hole. No exception, no log, just silence.
Exempt /api, own your 401s, and — for unauthenticated routes touching paid APIs — add the Sec-Fetch-Site check. Each layer does the one thing it's actually good at.
P.S. If you're on Next 16 and wondering why your background jobs are "succeeding" but doing nothing — check whether middleware is eating your routes before you tear apart your sync logic.