DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our entire scheduler is nine lines of vercel.json, and the secret is compared as two SHA-256 digests

Nakodo is a background product. A customer sets a campaign up once, and after that the work happens while nobody is looking: searching, reading, writing, sending, cleaning up after itself. The page that explains it to customers is how it works, and none of what it describes is triggered by a request from a browser.

So there has to be a clock. Ours is nine lines:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    { "path": "/api/cron/pipeline", "schedule": "*/5 * * * *" },
    { "path": "/api/cron/outreach", "schedule": "*/5 * * * *" },
    { "path": "/api/cron/maintenance", "schedule": "15 9 * * *" },
    { "path": "/api/cron/indexnow", "schedule": "20 * * * *" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That is 288 plus 288 plus 24 plus 1, so 601 invocations a day, and every one of them is a plain GET to a URL that anybody can type.

You can try them

They are live, so this is reproducible:

$ curl -s -o /dev/null -w '%{http_code}\n' https://nakodo.app/api/cron/pipeline
401
$ curl -s https://nakodo.app/api/cron/indexnow
Unauthorized
$ curl -s -o /dev/null -w '%{http_code}\n' -I https://nakodo.app/api/cron/outreach
401
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST https://nakodo.app/api/cron/pipeline
405
Enter fullscreen mode Exit fullscreen mode

Two small things fall out of that. A HEAD is 401 rather than 405, because Next derives HEAD from the exported GET, and the body is dropped afterwards. A POST is 405 before any code of ours runs, because the route exports no POST, which is a free refusal: the handler never executes, so the secret is never compared for a method we do not serve.

The other detail is in the response headers of the 401:

cache-control: public, max-age=0, must-revalidate
Enter fullscreen mode Exit fullscreen mode

A publicly cacheable refusal, which is fine at max-age=0, and a reminder that these routes sit outside the part of the app that thinks about sessions at all.

They are outside the auth boundary on purpose

Next 16 renamed middleware.ts to proxy.ts, and ours refreshes the session cookie and sends signed-out visitors to the login page. The matcher names what it skips, and the cron paths are in that list with the reason attached:

// - the cron endpoints (which use CRON_SECRET), the Stripe webhook (which
//   checks Stripe's signature) and the email endpoints (the Resend webhook,
//   which checks Resend's signature, and one-click unsubscribe, which a mail
//   client POSTs with no session);
matcher: [
  "/((?!_next/static|_next/image|alcy/|api/cron|api/stripe|api/email|...).*)",
],
Enter fullscreen mode Exit fullscreen mode

Three different callers, three different proofs: a shared secret, a provider signature, a mail client with no session at all. None of them has a cookie, so none of them can be checked by the thing that checks cookies. If the proxy did cover /api/cron, a cron invocation would be answered with a 307 to /login, Vercel would record a successful request, and nothing would run, forever, quietly.

The secret

import { createHash, timingSafeEqual } from "node:crypto";

// Vercel Cron sends `Authorization: Bearer $CRON_SECRET`. Without a configured
// secret every request is refused. Compared in constant time (as hashes, so
// the lengths match), so the secret can't be guessed from response times.
export function isCronRequest(request: Request): boolean {
  const secret = env("CRON_SECRET");
  if (!secret) return false;
  const digest = (v: string) => createHash("sha256").update(v).digest();
  return timingSafeEqual(digest(request.headers.get("authorization") ?? ""), digest(`Bearer ${secret}`));
}
Enter fullscreen mode Exit fullscreen mode

timingSafeEqual is the right function and it has a sharp edge: it throws a RangeError when the two buffers have different lengths. Any attacker-controlled string can therefore turn a comparison into a 500 unless you guard the length first, and guarding the length leaks the length. Hashing both sides removes the problem rather than handling it. Two SHA-256 digests are always 32 bytes, so the comparison is always legal, always constant time over the same number of bytes, and the length of the header tells you nothing.

The missing-secret branch is the other half. It returns false, not true, which means a deployment that forgot the environment variable has four crons that refuse their own scheduler rather than four open endpoints. The cost of that choice is that the symptom is invisible: in Vercel's cron log you see 401s, which is exactly what you would see if someone else were poking at the URL.

The skip

Three of the four routes ask one question before they start:

export async function cronSkipReason(): Promise<string | null> {
  if (MAINTENANCE) return "maintenance";
  try {
    await withTimeout(db.execute(sql`select 1`), 10_000, () => new Error("timed out"));
    return null;
  } catch (e) {
    console.error("cron: the database isn't answering, skipping this run", e);
    return "database unreachable";
  }
}
Enter fullscreen mode Exit fullscreen mode

MAINTENANCE is a boolean constant in a file, not a flag in a table, because the state it describes is "the database is not there" and a flag in the database cannot be read then. select 1 under a ten second timeout is the cheapest proof that it is back.

The important word is "skipping". Nothing is started, so nothing is left half done, and the next run picks the work up where it was left. For a job queue that is a free property: the work is rows, and rows wait.

The one that does not ask

// Hourly: tells Bing and the other IndexNow engines about pages added, changed
// or removed since the last run. A rejected submission answers 502, so the
// failed run shows in Vercel's cron log.
export async function GET(request: Request) {
  if (!isCronRequest(request)) return new Response("Unauthorized", { status: 401 });
  const result = await announceChanges();
  return Response.json(result, { status: "error" in result ? 502 : 200 });
}
Enter fullscreen mode Exit fullscreen mode

No cronSkipReason, and no maxDuration either. It is the only cron that never touches Postgres: it compares the page list in src/content/registry.ts, which is TypeScript, against what was last announced, which lives in Redis. A database check would be a question about something it does not use.

It is also the only one that reports failure as failure. The other three answer 200 with {"skipped":"maintenance"} in the body, which Vercel's log renders as a perfectly successful run. That is the thing in this file I would change: a skipped run and a working run should not look the same from the outside, and a 503 with a Retry-After would describe it honestly without making the platform retry in a way that matters.

Budgets inside the five minutes

// Runs every 5 minutes. Works for about 3.5 minutes, then stops claiming jobs.
// The rest of maxDuration covers jobs already started (AI calls time out at
// 30 seconds with one retry); unfinished jobs wait for the next run.
export const maxDuration = 300;

const result = await runPipeline({ trigger: "cron", timeBudgetMs: 220_000 });
Enter fullscreen mode Exit fullscreen mode

300 seconds of function, 220 seconds of claiming new work, 80 seconds of slack for the work already in flight. The gap is the whole design: a run that claims a job at second 299 either gets killed holding it or forces the queue to treat every claim as suspect. Spending the last 80 seconds finishing instead of starting is cheaper than any amount of cleanup logic.

Two runs can still overlap, because */5 is a request for a run every five minutes and not a promise that the previous one has returned. That is fine here for a reason that has nothing to do with the clock: claiming a job is FOR UPDATE SKIP LOCKED on one Postgres table, so a second run sees the rows the first one has not taken and gets on with those.

What the four actually do

In order of how often they run: the pipeline tick moves queued work along, the outreach tick reads replies and sends what is due, the daily maintenance run keeps the retention promise written on our privacy notice (YouTube data refreshed or deleted within 30 days, and it deletes in the same invocation that refreshes, in that order), and the hourly IndexNow run tells search engines which of the 76 public pages changed.

Four lines of JSON, one shared secret compared as two hashes, one boolean constant for the case where the database is gone, and 601 chances a day for any of it to be wrong.

Top comments (0)