Multi-tenant cron jobs: the authorization boundary most examples omit
Covers
Tenant scope, signed delivery, and idempotency
A tenant ID in the request body is not an authorization boundary. Follow the identity chain from the signed-in browser to delayed background work.
A tenant ID in JSON is not an authorization boundary
The common example accepts organizationId, saves it beside a cron expression, and filters list queries with the same value. It looks multi-tenant because every row has a tenant column. The browser is still choosing the tenant.
Change one request field and you may create work for another organization, fetch a schedule by ID, read its run history, or collide with its idempotency key. The risk reappears hours later when background work loads the row outside the original request session.
Build one identity chain from request to delayed work
Record where organization identity comes from and where it is enforced at every hop.
01 · RESOLVE
Server session
Organization and user come from trusted auth.
02 · ENFORCE
Every API path
Lists, IDs, writes, history, and idempotency share the scope.
03 · CARRY
Signed delivery
Durable context reaches delayed work without trusting a browser.
- 1 Your authentication layer resolves the active user and organization on the server.
- 2 Your permission layer decides whether that actor may read, create, edit, pause, or delete schedules.
- 3 The scheduling API scopes every list, direct lookup, mutation, history query, and idempotency lookup to that organization.
- 4 The created occurrence snapshots the durable organization and actor context.
- 5 Signed delivery carries that context to an allowlisted action; the action re-authorizes any decision that may have changed.
A signed callback proves the event came from the scheduling system and was not altered in transit. It does not prove the user still has permission to export data next Thursday. Your domain must decide whether schedule creation grants durable authority or execution checks current authority.
Overwrite protected fields at the server proxy
In a Next.js application, the React schedule UI should call a same-origin Route Handler. That handler resolves the real session and overwrites protected identity before forwarding the request.
export const incld = createIncld({
apiKey: process.env.INCLD_SECRET_KEY!,
webhookSecret: process.env.INCLD_WEBHOOK_SECRET!,
async resolveContext() {
const session = await auth()
if (!session?.user.id || !session.organizationId) return null
return {
user: { id: session.user.id },
organization: { id: session.organizationId },
permissions: session.user.permissions,
}
},
async authorize({ context, operation }) {
return can(context.user.id, context.organization.id, operation)
},
})
The incld proxy recursively removes browser-supplied organization, user, requester, approver, actor, and viewer fields. It then injects the trusted context into the request body, query string, and protected scope headers. Missing organization context fails closed for tenant-bound operations.
Do this even when the UI never exposes a tenant field. Attackers do not need your form to generate HTTP requests, and future client code can accidentally send stale workspace state.
Scope direct lookups, not only list endpoints
GET /schedules?organization=acme
is the obvious place to filter. The more dangerous endpoint is often GET /schedules/:id.
A globally unique UUID prevents guessing at scale; it does not authorize the one ID that leaks through a log, URL, screenshot, or support message.
| Path | Scope that must be enforced |
|---|---|
| List schedules | Project + organization; optionally actor/product visibility |
| Get, update, pause, resume, delete | The same scope, even with an exact ID |
| List runs and schedule events | Scope inherited from the parent schedule |
| Idempotent create | Key lookup inside the organization boundary |
| Operator dashboard | Explicit administrator scope, separately authenticated |
Avoid a helper that fetches by ID and asks callers to remember an authorization check afterward. Put scope in the database query so an out-of-tenant row is indistinguishable from a missing row. That shape is easier to preserve through refactors.
Carry durable scope into signed delivery
Hours or months after creation, there is no browser session. The run needs a snapshot of enough trusted context to route work without accepting identity from an arbitrary callback body.
incld signs the raw webhook body. Its framework adapter verifies the signature, maps the declared action, and exposes the stored organization and user context on the event. The follow-up client passed to the action is automatically organization-scoped when that durable context is present.
generate_report: {
async run({ payload, event, client }) {
const organizationId = event.context?.organization_id
if (!organizationId) throw new Error("Missing organization context")
await reports.enqueue({
organizationId,
accountId: payload.accountId,
idempotencyKey: event.idempotencyKey,
})
// client is scoped to the delivered organization for follow-up calls
},
}
An idempotency key needs a tenant boundary too
Idempotency is often implemented as a global unique key. That creates accidental coupling between customers: two organizations can reasonably choose the same domain key, such as weekly-report:42.
Either include the organization in a canonical key or enforce uniqueness on a compound scope such as project, organization, and key. Then use the delivery event ID as the deduplication key for the action side effect. Creation idempotency and execution idempotency solve different retry windows.
| Key | Protects against | Suggested scope |
|---|---|---|
| Schedule creation key | Double submit and client retry | Project + organization + intent |
| Occurrence/event key | Webhook retry and handler crash | One immutable delivered event |
| Domain operation key | Duplicate external side effect | Organization + business operation |
Keep cross-tenant tests beside the happy path
Authorization regressions are easiest to catch with two real tenants in the same test. Create the same-shaped resources for Acme and Beacon, authenticate as Acme, and exercise every path with Beacon’s exact ID.
- 1 Acme cannot list Beacon schedules or runs.
- 2 Acme gets not found when requesting Beacon’s known schedule ID.
- 3 Acme cannot update, pause, resume, or delete Beacon’s schedule.
- 4 A browser-supplied Beacon organization ID is stripped and replaced with Acme.
- 5 The same creation idempotency key can be used independently in both organizations.
- 6 A delivered occurrence contains the organization captured for its schedule and rejects a bad signature.
- 7 An operator route can cross tenant scope only with its separate administrator authorization.
Run those tests whenever you add a shortcut endpoint. Tenant leaks tend to enter through convenience: a new “get latest run” query, a support lookup, or a background job that loads by primary key because the caller already “knows” the tenant.
In a multi-tenant system, scope is part of the resource identity. If a method accepts only an ID, make sure the rest of that identity comes from trusted context rather than caller input.
Ship customer-facing schedules with the boundary intact.
incld hosts recurrence state, signed delivery, retries, history, and React UI. Your application keeps authorization, domain data, and the work itself.
Related engineering notes
How to build customer-facing recurring schedules in Next.js
The cron expression is the easy bit. Here is the complete path from a customer choosing a time to your application safely doing the work.
IANA timezones, DST, misfires, and overlap policies for SaaS schedules
“Every day at 9” is a product promise, not a UTC interval. Model the promise first, then decide what should happen when time or execution gets awkward.