Engineering notes
Architecture 10 min read

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.

The identity chain

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. 1 Your authentication layer resolves the active user and organization on the server.
  2. 2 Your permission layer decides whether that actor may read, create, edit, pause, or delete schedules.
  3. 3 The scheduling API scopes every list, direct lookup, mutation, history query, and idempotency lookup to that organization.
  4. 4 The created occurrence snapshots the durable organization and actor context.
  5. 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.

Trusted context in the incld Next.js adapter ts
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 schedulesProject + organization; optionally actor/product visibility
Get, update, pause, resume, deleteThe same scope, even with an exact ID
List runs and schedule eventsScope inherited from the parent schedule
Idempotent createKey lookup inside the organization boundary
Operator dashboardExplicit 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.

Route delayed work by signed durable context ts
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 keyDouble submit and client retryProject + organization + intent
Occurrence/event keyWebhook retry and handler crashOne immutable delivered event
Domain operation keyDuplicate external side effectOrganization + 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. 1 Acme cannot list Beacon schedules or runs.
  2. 2 Acme gets not found when requesting Beacon’s known schedule ID.
  3. 3 Acme cannot update, pause, resume, or delete Beacon’s schedule.
  4. 4 A browser-supplied Beacon organization ID is stripped and replaced with Acme.
  5. 5 The same creation idempotency key can be used independently in both organizations.
  6. 6 A delivered occurrence contains the organization captured for its schedule and rejects a bad signature.
  7. 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.