Cron is a janitor, not a guard
Enforce expiry inline on the request path and demote scheduled jobs to pure housekeeping, so a slow or missing cron can never extend anyone's access.
My gateway had an hourly cron that expired grants — flipped status from ACTIVE to EXPIRED once expiresAt passed. The request path trusted the status column: if (grant.status === "ACTIVE") return null (meaning “allowed”). Then I deployed to Vercel’s Hobby tier, which allows each cron to run once per day, and the design’s hidden assumption became a security hole with a number on it: a grant could keep working for up to 24 hours past its expiry, because nothing between the credential and the upstream API ever looked at the clock.
The uncomfortable part is that the hourly version had the same bug, just smaller — up to 59 minutes of post-expiry access. The cron schedule had silently become part of the security boundary, and nobody had decided that on purpose.
Janitor work and guard work
The fix, in commit 25f85a9 of Cookey, draws a line that’s worth stating as a rule: scheduled jobs may clean up state; they may never be the only thing standing between an expired credential and access. Every time-based cutoff — hard expiry, renewal-period lapse, token expiry — is checked inline on the request path against now. The sweep cron still exists, but its job description changed: flip statuses so admin dashboards read correctly, send digest notifications, delete stale rate-counter and nonce rows. If the cron never ran again, no request that should be denied would be allowed; the UI would just get stale.
This deliberately does not try to make the cron reliable. That’s the point — the cron’s reliability stopped mattering for enforcement, which is what lets it run daily on a free tier at all.
Moving enforcement inline
The inline check lives in the grant-state gate that every data-plane request passes through (apps/proxy/src/server/auth/resolve.ts). Before the fix, ACTIVE meant allowed. After, ACTIVE is merely a claim the clock gets to veto:
/**
* Returns null when the grant is ACTIVE, otherwise the state-specific error.
* Expiry is checked inline (not just via the sweep cron, which only runs
* daily on Hobby deployments): a grant past its hard expiry or renewal
* period is dead immediately, regardless of its stored status.
*/
export function checkGrantState(
grant: Grant,
): { status: number; code: ErrorCode; message: string } | null {
if (grant.status === "ACTIVE") {
const now = new Date();
const pastExpiry = grant.expiresAt !== null && grant.expiresAt < now;
const pastPeriod =
grant.currentPeriodEnd !== null && grant.currentPeriodEnd < now;
if (pastExpiry || pastPeriod) {
return {
status: getErrorStatus(ErrorCode.ERR_GRANT_EXPIRED),
code: ErrorCode.ERR_GRANT_EXPIRED,
message: pastExpiry
? "Grant has expired"
: "Grant's renewal period has lapsed — the owner can renew it",
};
}
return null;
}
// non-ACTIVE statuses map to their specific errors…
}
For a reader adapting this: the check belongs in whatever function already answers “is this principal allowed?” on the hot path — your auth resolver or policy gate — not in middleware that runs before you’ve loaded the principal, and not duplicated per-route. It’s a pure function of the row plus now, which makes it trivially unit-testable. The test file (apps/proxy/src/server/auth/__tests__/grant-state.test.ts) pins the property by name:
it("rejects inline when the hard expiry passed (before the sweep runs)", () => {
const result = checkGrantState(
grant({ expiresAt: new Date(Date.now() - 1000) }),
);
expect(result?.code).toBe("ERR_GRANT_EXPIRED");
expect(result?.status).toBe(403);
});
With enforcement moved inline, the cron demotion is a one-line schedule change in apps/proxy/vercel.json ("0 * * * *" → "0 3 * * *") and a comment update declaring the new contract:
// /api/cron/sweep (daily — Vercel Hobby allows one run/day per cron;
// expiry is also enforced inline in the pipeline, so the sweep is pure
// housekeeping)
There’s a second, quieter benefit to keeping the status flip in the sweep at all: expiry-by-status is what makes dashboards, notification digests, and “expired 3 days ago” lists cheap to query. Inline enforcement and background bookkeeping aren’t alternatives; they’re the same fact recorded at two different urgencies.
The same bug, one line wide
The cron is only the loudest instance of the delegation mistake: enforcement handed to an execution context that is not guaranteed to run. The one-line version hides inside request handlers on serverless platforms — the fire-and-forget write, recordUse(row).catch(() => {}), kicked off without an await so the response isn’t delayed. Vercel and Lambda may freeze the invocation the moment the response is sent, so a dangling promise doesn’t fail; it sometimes doesn’t run. That’s fine when the write is a lastUsedAt timestamp — and a security bug when the same call also wipes a show-once credential copy, as this gateway’s token path did. The classification is the same one the cron demotion forces: for every deferred write, decide whether it is best-effort observability or a correctness requirement wearing observability’s clothes. The first kind may be backgrounded (through the platform’s waitUntil, never a bare floating promise); the second is awaited before the response — with its failure still caught, because a tracking hiccup must not turn a valid request into a 500. Await-but-catch: must attempt, must not block success on failure.
How the two halves fail
The two mechanisms now degrade independently. If the sweep stops running entirely — misconfigured CRON_SECRET, platform outage, someone deletes the cron entry — enforcement is untouched; the visible symptoms are cosmetic (grants show ACTIVE in the admin list while returning 403 on use) and housekeeping debt (stale nonce and rate-counter rows accumulate until the next sweep). If, conversely, the inline check somehow regresses, the sweep still hard-expires grants within a day — a real but bounded backstop, and the unit test exists precisely so that regression doesn’t happen silently.
The remaining sharp edge is clock trust: inline checks compare against the app server’s clock. On a managed platform that’s fine; if you run this on your own metal, NTP drift now has security consequences, which is a trade you should make knowingly.
The schedule becomes a knob
The immediate payoff is that your cron schedule becomes a pure cost/freshness knob. Hourly, daily, on-demand from an admin button — the choice affects how quickly dashboards converge, and nothing else, so platform constraints like “one run per day on the free tier” stop being architectural constraints. The deeper change is to your threat model review: “what happens if this job doesn’t run?” gets a boring answer for every scheduled job in the system, because any job whose answer was scary has already had its enforcement half extracted into the request path.