Unprotected cron jobs and internal API routes: how to lock them down on Vercel and Supabase
By Obscura Security · Updated · Vercel, Next.js, Supabase, Cursor, Lovable, Bolt
Short answer
A route you meant only for a scheduler or for yourself (/api/cron/send-emails, /api/admin/reindex, /api/seed, /api/debug) is a public URL unless it checks for a secret. Make the route reject any request without your secret, delete seed and debug routes from production, and where you can, run scheduled work inside the database so it has no URL at all.
Apps built with AI tools collect small utility routes: a cron job that sends emails, a route to rebuild a search index, an endpoint to fill the database with sample data, one to print debug info. They are meant for you or a scheduler. The web has no concept of that. Every route is a public URL, and anyone who finds it can call it.
Why this happens
When you ask an AI tool for “a daily job that sends reminder emails”, you get a route like this:
// app/api/cron/send-emails/route.ts
export async function GET() {
await sendReminderEmails()
return Response.json({ ok: true })
}
It works when the scheduler calls it. It also works when anyone else does. Depending on the route, an open endpoint lets a stranger:
- trigger your email sending, over and over, at your cost and with damage to your sender reputation;
- run an expensive job like re-indexing or an AI batch over and over, at your cost;
- re-run a seed route and overwrite or duplicate your real data;
- read what a debug route prints, such as environment variables, user lists or stack traces.
Seed and test routes are often left in because the app “worked” with them. They tend to be the most dangerous, since they were written without thinking about who might call them.
How to check your own app
- List your routes. In Next.js, look under
app/apiandpages/api, and search for folders and files namedcron,admin,seed,test,debug,reset,migrate,reindex,webhook. - Check
vercel.jsonfor acronsentry. Everypathlisted there is a route the scheduler calls, and it needs a check. - For each route, read the code and ask: what stops a stranger from calling this? If the answer is “nothing”, or “it’s not linked anywhere”, it is open.
- To confirm, call the route without any credentials. Read the code first, because calling a live seed or email route will run it. Test it against a local copy or a staging deployment, not production data.
The fix
1. Vercel Cron: check the CRON_SECRET header
Add an environment variable named CRON_SECRET to your Vercel project, with a long random value. Vercel’s documentation asks for at least 16 characters. Generate one with:
openssl rand -hex 32
When Vercel calls your cron path, it adds Authorization: Bearer <your CRON_SECRET>. Your route must check it. This version compares in constant time and refuses to run if the secret isn’t set:
// lib/cron-auth.ts
import { timingSafeEqual } from 'node:crypto'
export function isAuthorisedCron(req: Request): boolean {
const secret = process.env.CRON_SECRET
if (!secret) return false // never fall back to "open" when the variable is missing
const expected = Buffer.from(`Bearer ${secret}`)
const given = Buffer.from(req.headers.get('authorization') ?? '')
return given.length === expected.length && timingSafeEqual(given, expected)
}
// app/api/cron/send-emails/route.ts
import { isAuthorisedCron } from '@/lib/cron-auth'
export async function GET(req: Request) {
if (!isAuthorisedCron(req)) {
return new Response('Unauthorised', { status: 401 })
}
await sendReminderEmails()
return Response.json({ ok: true })
}
The secret check matters. The common shortcut of comparing the header to a template string containing process.env.CRON_SECRET compares against the text Bearer undefined when the variable is missing, and a caller can send exactly that. The length test is there because timingSafeEqual throws if the two buffers differ in length.
Cron jobs are set up in vercel.json:
{
"crons": [
{ "path": "/api/cron/send-emails", "schedule": "0 22 * * *" }
]
}
2. Other schedulers and internal tools: a shared secret header
If something other than Vercel calls the route (GitHub Actions, an uptime service, your own script), use the same helper with a header you choose, such as x-internal-secret, and keep the value in that tool’s secrets store. Never put it in the repository or in client-side code.
3. Remove seed, test and debug routes from production
Delete them. If you still need them locally, keep them out of the deployed build:
export async function POST() {
if (process.env.NODE_ENV === 'production') {
return new Response('Not found', { status: 404 })
}
await seedDatabase()
return Response.json({ ok: true })
}
Deleting the file is safer, since a check can be removed by mistake. Consider a script you run from your own computer instead, like npm run seed, so the code never becomes a URL.
4. Supabase: schedule work inside the database
Supabase’s pg_cron extension runs SQL on a schedule with no public endpoint involved. If the job is database work, use that:
create or replace function public.delete_expired_invites()
returns void
language sql
as $$
delete from public.invites where expires_at < now();
$$;
-- Not callable through the Data API by users:
revoke execute on function public.delete_expired_invites() from public, anon, authenticated;
select cron.schedule(
'delete-expired-invites',
'0 3 * * *',
$$select public.delete_expired_invites()$$
);
Revoking execute matters because functions in the public schema can be called through the API with rpc(). See Supabase security definer functions if your function needs elevated rights.
If the job has to call an Edge Function, pg_cron can do it with pg_net. Store the secret in Supabase Vault and send it in a header, then check it inside the function. Edge Functions verify a JWT by default, so check Supabase’s documentation for how to combine that with a secret header of your own.
5. Add a rate limit as well
A secret keeps strangers out. A rate limit on anything that sends email or calls a paid API limits the damage if the secret ever leaks.
Mistakes to avoid
- Putting the secret in the URL.
/api/cron?key=abcends up in logs and browser history. Use a header. - A secret with a default.
process.env.CRON_SECRET || 'dev-secret'becomes the real secret if the variable is missing. - Protecting only
/api/cron. Check every route that does something you would not want a stranger to trigger. - Trusting the caller’s address or user-agent. Both are easy to fake.
- Forgetting preview deployments. Preview URLs can also be public. See Vercel preview deployments public.
Common questions
- Can anyone find my /api/cron route?
- Yes. Attackers and bots request common paths such as /api/cron, /api/seed, /api/test and /api/debug. If your repository is public, your vercel.json lists your cron paths for everyone to read. A URL that nothing links to is not protected.
- How does Vercel protect cron jobs?
- If you add an environment variable called CRON_SECRET to your project, Vercel sends it in an Authorization: Bearer header when it calls your cron route. Your route has to check that header itself. Vercel does not block other callers for you.
- Is it OK to check the user-agent of the request instead?
- No. The user-agent is a header any caller can set. Use a secret that only you and the scheduler know.
- Why use timingSafeEqual instead of ===?
- A normal string comparison can stop at the first different character, and in theory that difference in timing can leak the secret. crypto.timingSafeEqual compares in constant time. For a long random secret the risk is small, but it costs a few lines to avoid.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].