Back to Blog
Next.jsSecurityMiddlewareRate LimitingSupabase

Layered Security in Next.js Middleware Before Auth Runs

Bot probes for /wp-admin and /.env were hitting Supabase auth on every request — 400 unnecessary API calls in 2 minutes from a single scanner. Ordering middleware checks by cost (regex first, Supabase last) cut 60% of those calls before they reached auth.

May 18, 20267 min read

The problem showed up in the Supabase usage dashboard first. A spike in auth API calls during off-hours — 2am, 3am — that had no corresponding active user sessions. Something was hitting the session check endpoint repeatedly.

Every request to the portal ran through Supabase's getUser() to validate the session. That included requests from scanner bots probing for /wp-admin, /.env, /phpinfo.php. Endpoints that do not exist in a Next.js app. But the middleware still dutifully checked if the requester had a valid session before returning 404.

One bot session generated over 400 requests in 2 minutes. Every one of those hit Supabase. That is 400 unnecessary auth API calls from a single probe run.

The Ordering Insight

The fix is not blocking specific IPs or adding CAPTCHA. The fix is ordering middleware checks by cost.

Expensive operations should only run if the request survived all cheaper checks first. A Supabase session check involves an HTTPS round trip to Supabase's servers. A regex match on the request URL costs microseconds. Running the regex first costs almost nothing. Running it after Supabase already responded wastes real resources.

The ordering I settled on:

  1. Suspicious path detection — synchronous, regex, no I/O
  2. Body size check — synchronous, reads a header value
  3. Rate limiting — asynchronous, single Redis operation
  4. CORS validation — synchronous, checks headers
  5. Session check — asynchronous, Supabase HTTPS call

A request only reaches step 5 if it passed steps 1 through 4. Bot probes for WordPress admin paths never reach Supabase.

Suspicious Request Detection

The multi-encode problem took me longer to think through than I expected. A request URL like %253Cscript%253E decodes once to %3Cscript%3E, then again to <script>. URL-encoded payloads are sometimes double or triple-encoded specifically to bypass single-decode filters. I decode the URL three times and check the fully decoded result.

Patterns that matter in practice, based on what actually showed up in logs:

  • Path traversal: ../ sequences in the URL path
  • Null byte: %00 in any URL component
  • CRLF injection: %0d or %0a in the URL
  • Environment file probes: requests ending in .env, .env.local, .env.production
  • PHP scanner paths: /phpinfo.php, /wp-config.php, /wp-admin/, etc.
  • SQL injection in query params: UNION SELECT, DROP TABLE, ' OR '1'='1 patterns in the query string

SQL injection in query params was something I added after seeing it in logs. Search endpoints were getting probed with query strings containing SQL fragments. These are not going to work against a parameterized query, but they hit the server and generate log noise. Blocking them at middleware keeps the noise out of application logs.

I hit one false positive. An admin path I had named /template-injection-monitor triggered a regex I had written to catch template injection probes ({% patterns). Legitimate path, bad regex. I narrowed the pattern to require the injection syntax inside query parameters rather than matching path segments.

Rate Limiting Before Auth

Credential stuffing hits auth endpoints with lists of username/password combinations. Without rate limiting, there is nothing to stop it except Supabase's own rate limits, which exist but are not tuned to your specific app's traffic patterns.

I put the rate limit check before the session check in middleware. A request that exceeds the limit never reaches Supabase. The check uses Upstash Redis, which works at edge runtime because it is HTTP-based rather than TCP.

For auth endpoints: 5 attempts per 15 minutes per IP. For enrollment submission: 30 per minute. The numbers came from looking at PostHog data on legitimate user behavior. Peak legitimate usage was well below both thresholds.

The device fingerprint cookie adds a third dimension to the rate limit key. The key structure is rate_limit:{endpoint}:{ip}:{fingerprint}. To bypass the rate limit, you need to rotate both your IP and your device fingerprint. Rotating a fingerprint requires clearing cookies, which is friction most automated tools do not handle well.

What I Did Not Do

Deep packet inspection on request bodies. Middleware runs before the request body is parsed in Next.js. Reading the body in middleware requires consuming it, which means the downstream handler cannot read it again without explicit re-streaming. The complexity is not worth it. Body validation happens in server actions where it belongs.

Blocking entire IP ranges or ASNs. Too many false positives. Office buildings share IPs. Cloud provider IP ranges include legitimate users. Blanket IP range blocking generates support tickets without meaningfully improving security.

CAPTCHA in middleware. The UX cost is too high. CAPTCHA on every auth attempt would affect all users, not just the automated ones. Rate limiting with appropriate thresholds handles the realistic threat model at this scale without affecting legitimate users.

What the Logs Showed

After adding suspicious path detection, about 60% of the requests that had been hitting Supabase stopped reaching it. Most were WordPress scanner probes and environment file requests that regex-matched immediately.

The patterns in the blocked request logs also told me where to tune next. A cluster of requests probing for specific Laravel file paths suggested someone was running a Laravel-specific scanner. I added those paths to the block list.

Log everything that middleware blocks. The patterns in those logs are more useful than any threat intelligence feed for your specific deployment.

Found this useful?

Share it with someone who'd appreciate it.

https://wardvisual.com/blogs/nextjs-middleware-security-layers

Eduardo Manlangit Jr.
Eduardo Manlangit Jr.

@wardvisual · 🇵🇭 Dasmarinas City, Cavite PH

Full-stack engineer. Business systems, database optimization, and operations software.

Follow