Our Next.js Middleware Silently Bypassed Auth on 23 Admin Routes for 11 Days
The security auditor's Slack message arrived at 11:42 AM on a Friday: "Hey — I can hit
/api/admin/users with a completely invalid JWT and get back real data. Is that
intentional?" It was not intentional. We had migrated to Next.js 15 App Router eleven days
earlier, moved all authentication into a single middleware file, ran our test suite — green —
and shipped it. For eleven days, 23 admin API routes had been sitting open, protected by
middleware that thought it was verifying tokens but was silently swallowing every verification
failure without blocking a single request.
Production Failure
We were migrating a B2B SaaS platform — about 4,200 active business accounts — from a hybrid
PHP/Next.js Pages Router architecture to a full Next.js 15 App Router setup on Vercel. The
migration had been clean: TTFB dropped from 380 ms to 71 ms, our Lighthouse scores improved
across the board, and the team was confident. One of the big wins was consolidating all
authentication logic into a single middleware.ts at the project root, which ran
before every matched route.
Our middleware used jsonwebtoken to verify RS256 JWTs issued by our auth service.
On our old Pages Router setup it had worked flawlessly for two years. We ported the same
verification logic directly — same library, same keys, same algorithm. We tested it in local
dev. Everything worked. We shipped on March 13th.
Eleven days later, a quarterly penetration test caught it: every request to /api/admin/*
returned 200 with real data regardless of what JWT was in the Authorization header.
Expired tokens. Garbage strings. No token at all. Didn't matter.
False Assumptions
Our first assumption: "We tested this locally and it worked, so the middleware logic is correct."
This was true — locally. What we didn't know was that Next.js middleware runs on the
Edge Runtime by default in production on Vercel, which is not the same as the Node.js
runtime it uses locally during next dev.
Our second assumption: "If JWT verification fails, it throws, we catch it, and we return a 401." Also true — but with a fatal gap. The catch block returned a 401 except when the route matched our admin API pattern, which had a separate branch that was supposed to do role-checking after verification. That branch was never reached because verification threw — but the catch block re-routed to the wrong response path.
Third assumption: "Our CI test suite covers the middleware." It did — but our tests ran under Jest with Node.js, not the Edge Runtime. The bug was environment-specific, invisible in any test environment we ran.
Investigation
Once the auditor flagged it, I reproduced it in 90 seconds: sent a curl request
to /api/admin/users with Authorization: Bearer garbage and got a
200 with paginated user data. Sat there for a moment. Then started pulling logs.
Vercel's runtime logs showed middleware executing — no errors, no exceptions, just clean
200 responses from the downstream route handlers. That was the first clue: middleware was
running but not throwing. I added a debug log and redeployed: nothing printed from
the verification try block. The jsonwebtoken verify() call was
never completing — it was silently failing at import time.
import jwt from 'jsonwebtoken';
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
try {
const decoded = jwt.verify(token ?? '', process.env.JWT_PUBLIC_KEY!);
if (req.nextUrl.pathname.startsWith('/api/admin')) {
// Role check — only reached if verify() succeeds
const payload = decoded as { role: string };
if (payload.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.next();
} catch (err) {
// BUG: This block was hit for EVERY request in Edge Runtime
// because jsonwebtoken couldn't access Node crypto APIs
// And it fell through to NextResponse.next() for admin routes
if (req.nextUrl.pathname.startsWith('/api/admin')) {
// Intent: return 401. Reality: this condition was evaluated AFTER
// the assignment below in an early refactor, and the early return was lost
console.error('Auth failed', err);
}
return NextResponse.next(); // 🔥 fell through here for ALL routes
}
}
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*'],
};
The bug had two layers: one environmental, one logical.
Layer 1 — Edge Runtime incompatibility: jsonwebtoken internally
uses Node.js's crypto module for RSA signature verification.
The Edge Runtime (V8 isolates on Cloudflare Workers infrastructure) doesn't expose
Node.js built-ins. When jwt.verify() ran in Edge, it hit an internal
crypto is not defined error and threw immediately — before verifying anything.
Layer 2 — Logic bug in the catch block: An earlier refactor had intended
to add a 401 response inside the catch for admin routes. The developer added the
if (startsWith('/api/admin')) check but forgot the return before
the final NextResponse.next(). So the catch block logged the error and then
returned 200 for every single route, including admin.
WHAT WE THOUGHT WAS HAPPENING (local dev, Node runtime):
Request → middleware → jwt.verify() ──success──► role check ──pass──► route handler
└──fail──► 401 Unauthorized
WHAT ACTUALLY HAPPENED (Vercel Edge Runtime):
Request → middleware → jwt.verify() ──always throws (no Node crypto)──► catch block
└──logs error
└──NextResponse.next() ← NO RETURN
└──falls through
└──200 OK ← every time
Root Cause
The root cause was a mismatch between development and production runtimes that we had no
mechanism to detect. Next.js middleware runs on the Edge Runtime in Vercel production but
runs on Node.js in next dev. The jsonwebtoken library is
not Edge-compatible — it says so in its README, in a section we didn't read carefully
enough during the migration.
The correct Edge-compatible approach is to use the Web Crypto API directly or use a
library built for it, like jose, which uses SubtleCrypto
under the hood and works in any Web Standards environment.
The secondary cause was a missing return in the catch block. A logic bug
that any code review should have caught — but the catch block itself was a recent addition,
it was short, and reviewers trusted the test suite to catch behavioral problems. The test
suite ran under Node.js and never exercised the Edge path.
The Fix
We deployed a fix within 47 minutes of the auditor's message. Three parts:
1. Replace jsonwebtoken with jose in middleware:
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { NextRequest, NextResponse } from 'next/server';
const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));
export async function middleware(req: NextRequest) {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'],
audience: process.env.AUTH_AUDIENCE,
});
if (req.nextUrl.pathname.startsWith('/api/admin')) {
if (payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.next();
} catch (err) {
// Explicit return — no fall-through possible
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*'],
};
2. Add an Edge Runtime smoke test using Playwright against the Vercel
preview URL in CI, specifically hitting /api/admin/users with an invalid
token and asserting a 401:
test('admin routes reject invalid JWT in Edge', async ({ request }) => {
const res = await request.get('/api/admin/users', {
headers: { Authorization: 'Bearer invalid.garbage.token' },
});
expect(res.status()).toBe(401);
});
test('admin routes reject missing JWT', async ({ request }) => {
const res = await request.get('/api/admin/users');
expect(res.status()).toBe(401);
});
test('admin routes reject expired JWT', async ({ request }) => {
// Use a pre-generated expired RS256 token from test fixtures
const res = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${EXPIRED_ADMIN_JWT}` },
});
expect(res.status()).toBe(401);
});
3. Add export const runtime = 'edge' declarations to all
/api/admin/* route handlers so that if a library incompatibility surfaces
in the future, it fails at build time rather than silently at runtime.
Incident Response
With the fix deployed, we ran a full audit of Vercel's request logs for the 11-day window.
We were looking for requests to admin routes from IP addresses not associated with any
of our internal team or the pen test firm. We found 3 requests from unknown IPs —
all to /api/admin/health, which returns nothing sensitive. The pen tester
confirmed those were their automated scanners. No data exfiltration. No account
modifications. We notified affected enterprise customers per our SLA anyway, because
transparency beats silence every time.
Response timeline:
11:42 AM — Auditor reports the finding
11:50 AM — Reproduced locally and confirmed in production
12:05 AM — Root cause identified (Edge runtime + logic bug)
12:29 AM — Fix deployed to Vercel (PR merged, auto-deploy triggered)
12:31 AM — Confirmed fixed via curl
01:15 PM — Log audit complete, no malicious access found
02:00 PM — Customer notification sent to 14 enterprise accounts
Lessons Learned
1. Your test environment is lying to you about the Edge Runtime.
Jest runs on Node.js. next dev runs middleware on Node.js. The only way to
test Edge Runtime behavior is to run against an actual Edge deployment — Vercel preview
URLs, or next build && next start with experimental.runtime = 'edge'
forced. We now run a small suite of Playwright end-to-end security tests against every
Vercel preview deploy as part of our PR check.
2. Not every Node.js library works in Edge. Check the runtime compatibility
before adopting any library in middleware. The jose library is purpose-built
for Web Standards environments and covers everything jsonwebtoken does.
Switching cost: about 30 minutes and 40 lines changed.
3. Catch blocks need return statements too. Any catch block
that has a conditional inside it is a fall-through risk. Code review culture should
flag catch blocks that don't have an explicit return on every branch.
We added an ESLint rule for this: consistent-return set to error in our config.
4. Quarterly pen tests pay for themselves. This finding took 11 days to surface because it was in the next scheduled test window. We moved to monthly automated DAST scans (we use Nuclei templates on a cron) and quarterly manual pen tests. The automated scan would have found this on day two.
5. When in doubt, fail closed. Our fix starts with an explicit token presence check before doing anything else. If the token is missing, return 401 immediately. No fall-through possible. The default answer for an auth middleware is always "deny" — the code has to explicitly earn the 200.
The platform has been clean since. But that 11-day window is a number I think about every time I write a new middleware function. The Edge Runtime is not Node.js. Write it on a sticky note and put it on your monitor.