Securealy
Check · Security · blocker · verified

Admin and API routes refuse anonymous requests

Routes found in the sitemap and bundle answer 401, 403 or 405 to an anonymous request, never data.

Severity
blocker (one caps the grade at D, two at F)
Tier
Verified: after you prove ownership
Category
Security
Fix
Free, in full, below
Id
open-admin-routes

What this means

Passing means every admin and API route we can find answers an anonymous request with 401, 403 or 405, and never with data. Failing means at least one route served content to a request that carried no credential at all. A login screen in front of a page is not a lock if the API behind it answers anyone who asks it directly.

How we test it

This check runs after you verify ownership (DNS TXT _securealy, the securealy-verify meta tag, or /.well-known/securealy.txt). We discover routes from your sitemap and from the URLs in your first-party bundle, then probe each one anonymously, once, with no credential and no forged headers. Pass is 401, 403 or 405 on every route; a 404 is not a fail. Fail is any route that returns data, and the check is a launch blocker.

Why it matters

Client-side-only authorization is the failure behind a widely reported leak from an app built with AI: the page hid the data and the API handed it out. In the Fix Kit's own audit, 58 client reporting endpoints answered anonymous requests with live ad spend, conversions and keyword data; a client found it. One client's endpoints had been gated months earlier and the fix was never carried to the others. The root cause was the same every time: a protection applied once, to one place, and never carried across.

The fix

Every endpoint starts closed. It imports one shared gate before it does anything else, and serving data is the feature you add to a gate, not the other way round. The Fix Kit's templates/gate.js (Module 3) is that gate: method check, allow-listed CORS, Cache-Control: private, no-store, a rate-limit skeleton, a credential read from the Authorization header only, a Supabase session verified against the auth server, and a per-request check that the user holds an active grant on this tenant.

  1. Copy templates/gate.js to api/_lib/gate.js (Vercel functions) or lib/gate.ts (Next.js, adapting the request shape as the file's comments describe). Set ALLOWED_ORIGINS and the env it reads: SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY, SERVER_SECRET.

  2. In every handler, make the gate the first statement. It returns null once it has already sent 401, 403, 405 or 429:

const { gate } = require('../_lib/gate.js');
module.exports = async (req, res) => {
  const who = await gate(req, res, { methods: ['GET'], tenant: 'TENANT_SLUG' });
  if (who === null) return;
  // ... only now read the body, query the database, serve data
};

Cron handlers use { methods: ['GET', 'POST'], serverOnly: true }. The few paths that are public on purpose use { public: true } and go, one per line with a reason, in templates/public-allowlist.txt. Keep that file short: every line is a door you decided to leave open.

  1. Delete any guard that checks a Referer or Origin header, a cookie set by the login page, or a password compared in page JavaScript. One curl flag forges a header. Ask what it costs an attacker to satisfy a guard; if the answer is one flag, it is decoration.

  2. Prove it in code, then against production:

grep -rL "gate.js\|lib/gate" api app/api 2>/dev/null | grep -vE '/_|_lib' ; echo "---- files above do NOT import the gate; expect none"
grep -rn -B2 "await gate(" api app/api 2>/dev/null | grep -E "req\.body|JSON\.parse" ; echo "---- expect no output"

Push, wait for Ready, then one request per path with a one-second gap, never a loop:

bash MODULE-03-lock-it-down/templates/audit.sh probe

Expect probe: 0 failures. The script also forges Referer and Origin and sends an empty POST to each path; an empty POST that returns 400 means validation ran before authentication, so move the gate up.

A deeper runbook for this class is in the Fix Kit, Module 03.

Prompt for your AI

Paste this into the coding agent that built the app.

List every API route and admin page in this repo (api/, app/api/, pages/api/, and anything vercel.json routes) and write them one per line to MODULE-03-lock-it-down/surfaces.txt.
Install the shared gate from MODULE-03-lock-it-down/templates/gate.js at api/_lib/gate.js and make `await gate(req, res, {...})` the first statement in every handler, before any body parsing or database call.
Mark only the routes I name as public with { public: true } and list them in templates/public-allowlist.txt; everything else requires a credential.
Remove any guard based on Referer, Origin, a login cookie or a password compared in client code.
Run the two verification greps from Module 3 step 2 and show me the output; expected: no handler listed and no body parse before the gate.
STOP and wait for my yes before pushing to production.
After the deploy shows Ready, run `bash MODULE-03-lock-it-down/templates/audit.sh probe` once and show me the output; expected: probe: 0 failures.

Next

Grade your own URL

Every grade lists this check with its evidence for your site. Two free grades a day.