When you build a platform that runs customer apps, you need to control who can use each app and which data they can access. An app may have both public and private endpoints.
Customers can add routes and change app code independently of your platform. Enforce permissions for routes and data operations in platform-owned code.
Enforce access at two points:
- Before the app runs: require a list of public and private routes when the app is published. Validate that list and enforce its rules before invoking app code.
- When the app accesses data: check permissions inside platform-owned operations. Signing in must not let Bob read Alice's private report.
With Workers for Platforms, check requests in the dispatch Worker and pass scoped data operations to user Workers.
flowchart TB Request --> Gate[Dispatch Worker<br/>Authenticate and check route] Gate -->|Denied| Reject[Return 401, 403, or 404<br/>App does not run] Gate -->|Allowed| App[User Worker] App --> Capability[Platform capability<br/>Check access to this record] Capability --> Data[(Private data)]
Authentication establishes identity. Authorization determines which routes, actions, and records that identity can access.
Choose checks according to what needs protection:
| Requirement | Enforcement point |
|---|---|
| The whole app requires sign-in | Dispatch Worker, before invocation |
| Routes mix public and private access | Platform-owned method/path policy |
| Routes are unknown, but data permissions are known | Platform-owned data operations |
| Routing and policy must stay together | Registered handlers with access metadata |
| Public code needs fewer privileges | Separate Workers with different capabilities |
| App libraries call HTTP APIs | Outbound Worker |
Combine route checks with data-operation checks for mixed-access apps.
Your platform does not need to predict every future route. Require each app to declare its routes when it publishes:
[
{ "method": "GET", "path": "/", "access": "public" },
{ "method": "GET", "path": "/profile", "access": "member" },
{ "method": "GET", "path": "/admin", "access": "reports:admin" }
]This manifest is a policy format your platform implements. Workers does not infer intended access from app code.
flowchart LR Upload[Customer submits code + policy] --> Validate[Platform validates policy] Validate --> Release[Version code + policy together] Release --> Publish[Publish the matching release]
The platform owns these publication rules:
- Match the tenant to the authenticated app owner.
- Reject duplicate or ambiguous routes.
- Restrict which routes can become public.
- Prevent changes that weaken required permissions.
- Keep code and policy versions consistent during rollouts.
Upload a new script name first. Then switch the dispatch Worker to that script and its matching policy.
Match both method and path. A new route stays inaccessible until the platform accepts its policy.
export function checkRoute(request, routes, tenant, identity) {
const path = new URL(request.url).pathname;
const route = routes.find(
(route) => route.method === request.method && route.path === path,
);
if (!route) return new Response("Route not published", { status: 404 });
if (route.access === "public") return null;
if (!identity) return new Response("Sign in", { status: 401 });
if (
identity.tenant !== tenant ||
(route.access !== "member" && !identity.permissions.includes(route.access))
) {
return new Response("Forbidden", { status: 403 });
}
return null;
}export type Identity = {
userId: string;
tenant: string;
permissions: string[];
};
export type Route = {
method: string;
path: string;
access: "public" | "member" | "reports:admin";
};
export function checkRoute(
request: Request,
routes: Route[],
tenant: string,
identity: Identity | null,
): Response | null {
const path = new URL(request.url).pathname;
const route = routes.find(
(route) => route.method === request.method && route.path === path,
);
if (!route) return new Response("Route not published", { status: 404 });
if (route.access === "public") return null;
if (!identity) return new Response("Sign in", { status: 401 });
if (
identity.tenant !== tenant ||
(route.access !== "member" && !identity.permissions.includes(route.access))
) {
return new Response("Forbidden", { status: 403 });
}
return null;
}Here, member means membership in the app tenant. Add an app-membership check if only some tenant members may enter.
Use one path interpretation across the gate and app. This example matches exact URL paths; it grants no wildcard access.
Implement authenticate(request) in auth.ts using your existing identity provider or session service. This integration must:
- Verify the session or token, including expiry and intended audience.
- Return a verified identity, or
nullwhen credentials are absent. - Reject invalid credentials rather than trust caller-supplied identity headers.
Resolve deployment from your platform's app registry. Its tenant, routes, and script name must not come from app code.
Place this gate in the dispatch Worker request handler:
import { checkRoute } from "./policy";
import { authenticate } from "./auth";
let identity;
try {
identity = await authenticate(request);
} catch {
return new Response("Invalid session", { status: 401 });
}
const denied = checkRoute(
request,
deployment.routes,
deployment.tenant,
identity,
);
if (denied) return denied;
// Forward only what this GET-only example needs. Keep session credentials out.
const appRequest = new Request(request.url, { method: "GET" });import { checkRoute, type Identity } from "./policy";
import { authenticate } from "./auth";
let identity: Identity | null;
try {
identity = await authenticate(request);
} catch {
return new Response("Invalid session", { status: 401 });
}
const denied = checkRoute(
request,
deployment.routes,
deployment.tenant,
identity,
);
if (denied) return denied;
// Forward only what this GET-only example needs. Keep session credentials out.
const appRequest = new Request(request.url, { method: "GET" });After the gate succeeds, invoke the app through the dispatch namespace binding:
const app = env.DISPATCHER.get(deployment.scriptName, {
props: { identity },
});
return app.fetch(appRequest);const app = env.DISPATCHER.get(deployment.scriptName, {
props: { identity },
});
return app.fetch(appRequest);All app ingress must pass through the dispatch Worker.
For a wholly private app, require membership on every request. For mixed-access apps, use the route policy to select public exceptions.
A route check can require Bob to sign in, but it cannot prevent app code from requesting Alice's report. Keep permission checks inside the platform operations that read private data, so every new route uses the same checks.
An app can omit checkAuth() or ignore its result. Give it an operation that performs authorization and data access together.
sequenceDiagram
participant Caller as Bob
participant Platform as Dispatch Worker
participant App as User Worker
participant Reports as Platform Reports capability
Caller->>Platform: Request with Bob's session
Platform->>App: Capability scoped to Bob
App->>Reports: read("alice")
Reports-->>App: 404 — record does not belong to Bob
App-->>Caller: 404
Keep the data and implementation in the dispatch Worker. This example uses synthetic records:
import { WorkerEntrypoint } from "cloudflare:workers";
const records = [
{ id: "alice", tenant: "acme", owner: "alice", text: "Alice's report" },
{ id: "bob", tenant: "acme", owner: "bob", text: "Bob's report" },
];
export class Reports extends WorkerEntrypoint {
read(id) {
const { identity, tenant } = this.ctx.props;
if (!identity) return { status: 401, error: "Sign in" };
if (
identity.tenant !== tenant ||
!identity.permissions.includes("reports:read")
) {
return { status: 403, error: "Forbidden" };
}
const report = records.find(
(record) =>
record.id === id &&
record.tenant === tenant &&
record.owner === identity.userId,
);
if (!report) return { status: 404, error: "Report not found" };
return { status: 200, report: { text: report.text } };
}
}import { WorkerEntrypoint } from "cloudflare:workers";
const records = [
{ id: "alice", tenant: "acme", owner: "alice", text: "Alice's report" },
{ id: "bob", tenant: "acme", owner: "bob", text: "Bob's report" },
];
type Scope = { identity: Identity | null; tenant: string };
export class Reports extends WorkerEntrypoint<Cloudflare.Env, Scope> {
read(id: string) {
const { identity, tenant } = this.ctx.props;
if (!identity) return { status: 401, error: "Sign in" };
if (
identity.tenant !== tenant ||
!identity.permissions.includes("reports:read")
) {
return { status: 403, error: "Forbidden" };
}
const report = records.find(
(record) =>
record.id === id &&
record.tenant === tenant &&
record.owner === identity.userId,
);
if (!report) return { status: 404, error: "Report not found" };
return { status: 200, report: { text: report.text } };
}
}Create the capability using verified platform context. The app can choose a record ID, but cannot choose the capability's identity:
const reports = ctx.exports.Reports({
props: { identity, tenant: deployment.tenant },
});const reports = ctx.exports.Reports({
props: { identity, tenant: deployment.tenant },
});Pass the capability with the invocation:
const app = env.DISPATCHER.get(deployment.scriptName, {
props: { reports },
});
return app.fetch(appRequest);const app = env.DISPATCHER.get(deployment.scriptName, {
props: { reports },
});
return app.fetch(appRequest);The customer app calls the capability through RPC:
const result = await this.ctx.props.reports.read(reportId);
return Response.json(result, { status: result.status });const result = await this.ctx.props.reports.read(reportId);
return Response.json(result, { status: result.status });Use Outbound Workers to restrict network access to authorized destinations and operations. Otherwise, another data-access path could bypass the capability.
Use these variations when the app interface requires them:
| Pattern | Example | Why |
|---|---|---|
| Registered handlers | { method: "GET", path: "/profile", access: "member", handler: "profile" } |
Select policy and handler from the same platform-owned entry. A handler name does not grant data access. |
| Public app shell | Public HTML; protected report reads through reports.read(id) |
The platform can protect data without enumerating every frontend route. |
| Separate components | Public Worker receives {}; private Worker receives { reports } |
Public code has no private capability. Keep their storage separate too. |
| Outbound HTTP gateway | Allow GET https://reports.internal/reports/:id, then check record access |
Existing HTTP clients can use the same authorization model. Ignore identity headers supplied by app code. |
If the outbound gateway injects credentials, authorize the destination and operation first. Hiding a token does not limit what that token can do.