Protect routes with authz
Your generated workspace service declares its demo procedures public. After chapters 3 and 4 it
fronts data with real consequences: the member list of a specific team. The route that returns it
has to do two things at once, and both must be typed. It has to know which team is being asked for — a workspace id in
the path, not a string you fish out of the URL by hand — and it has to fail closed: no credential
means 401 before the handler runs, and a valid credential without the right scope means 403.
This chapter builds exactly that pair. You declare the members procedure once with
baseContract.route(...).meta({ access: ... }), so its HTTP method, path, input/output schemas, and
authorization requirements live on the same contract value used by the service and its clients.
Then createContractAuthorizer() projects that declaration into the service builder's
.withAuthn() / .withAuthz() middleware. The result is the combination this chapter's title
promises: a typed route and its authorization gate, from one source of truth.
What you will build
A guarded GET /api/workspace/:workspace/members route. One oRPC procedure contract declares its
path, typed workspace/limit/offset input, typed output, and required workspace:read scope. The
service implements that exact contract and gates it with .withAuthn() (which turns each request
into a Principal) and .withAuthz() (which enforces the procedure metadata). By the end an
unauthenticated request returns 401 UNAUTHORIZED, a request with the wrong scope returns
403 FORBIDDEN, and a correctly-scoped request returns 200 with a typed member list — the exact
three outcomes the framework's own test asserts.
Prerequisites
- The
workspaceservice and its typed contracts from chapter 1. - The
Membermodel and theworkspaceDbclient from chapter 3 — the route lists members from that datasource. - The contract metadata vocabulary is built into
@netscript/contracts, and the route-authz seam into@netscript/service/auth; neither needs the auth plugin to type-check, though you run the service under Aspire to exercise it live.
Confirm the workspace still builds before you change it:
# In my-workspace/
deno task check
Step 1 — Declare access on the procedure contract
Before the guard, give the route a typed identity and policy. Create the procedure in the shared
contracts/ tree so the service and every client import the same object. The route uses oRPC's
{workspace} placeholder; the default service REST mount adds /api at runtime.
// contracts/versions/v1/workspace.contract.ts
import { implement } from '@orpc/server';
import { baseContract } from '@netscript/contracts';
import { z } from 'zod';
const MembersInput = z.object({
workspace: z.string().min(1),
limit: z.coerce.number().int().positive().max(100).default(20),
offset: z.coerce.number().int().nonnegative().default(0),
});
const Member = z.object({
id: z.string(),
workspaceId: z.string(),
subject: z.string(),
role: z.string(),
});
const MembersOutput = z.object({
workspace: z.string(),
limit: z.number().int(),
offset: z.number().int(),
subject: z.string(),
members: z.array(Member),
});
export const WorkspaceContractV1 = {
members: baseContract
.route({ method: 'GET', path: '/workspace/{workspace}/members' })
.meta({
access: {
authentication: 'required',
authorization: { scopes: ['workspace:read'] },
},
})
.input(MembersInput)
.output(MembersOutput),
};
export const WorkspaceV1 = implement(WorkspaceContractV1);
The .meta({ access: ... }) block is the source of truth for the guard. It uses the one
NetScriptProcedureMeta.access vocabulary: authentication is none, optional, or required,
and authorization may declare readonly scopes and roles. There is no separate path-policy map
to keep aligned with this route.
| Name | Type | Description |
|---|---|---|
.meta({ access }) |
NetScriptProcedureMeta |
Stores access policy on the procedure that owns the route and schemas. |
authentication |
'none' | 'optional' | 'required' |
Declares whether the operation is public, optional-auth, or authenticated. |
authorization.scopes |
readonly string[] |
Every declared scope must be present on the authenticated Principal. |
authorization.roles |
readonly string[] |
Every declared role must be present on the authenticated Principal. |
Step 2 — Build the authenticator and contract authorizer
Now the guard. Define the credentials, then construct the authorizer from the contract itself. A
read credential carries workspace:read; a write credential does not. The authorizer reads the
required scope from WorkspaceContractV1.members — no duplicated path matcher:
// services/workspace/src/auth.ts
import {
createContractAuthorizer,
createStaticCredentialAuthenticator,
} from '@netscript/service/auth';
import { WorkspaceContractV1 } from '../../../contracts/versions/v1/workspace.contract.ts';
export const authenticator = createStaticCredentialAuthenticator({
credentials: {
read: {
subject: 'user:reader',
scopes: ['workspace:read'],
roles: ['reader'],
},
write: {
subject: 'user:writer',
scopes: ['workspace:write'],
roles: ['writer'],
},
},
});
export const authorizer = createContractAuthorizer(WorkspaceContractV1);
| Name | Type | Description |
|---|---|---|
createStaticCredentialAuthenticator(opts) |
AuthenticatorPort |
Maps bearer tokens to principals — each credential carries a subject, scopes, and roles. Good for tests and machine-to-machine callers. |
createContractAuthorizer(contract, { fallback?, rawRoutes? }) |
ContractPolicyAuthorizerPort |
Traverses procedure-local access metadata and binds it to the builder's real REST/RPC paths and aliases. rawRoutes declares exact, authentication-required raw routes; any other unmatched route is denied. |
createScopeAuthorizer(opts) |
MatchAwareAuthorizerPort |
Supported legacy path-rule authorizer; standalone, or a fallback only when a matched procedure has no metadata. |
.withAuthn({ authenticator, protect?, allowAnonymous? }) |
builder stage |
protect defaults to ['/api']; allowAnonymous defaults to ['/health']. |
.withAuthz({ authorizer, denyByDefault? }) |
builder stage |
denyByDefault defaults to true — fail closed when no decision is reachable. |
defineService() requires an explicit auth policy and refuses to start when it is missing. With
auth installed, netscript service add wires browserAuthenticator and
createContractAuthorizer(router) into the generated entrypoint. Procedures declaring
access: { authentication: 'none' } remain public; a protected procedure declares
authentication: 'required', as our members procedure does. /health, /api/openapi.json, and
/api/docs remain anonymous health and discovery endpoints in that generated posture. Public
discovery does not grant access to protected REST or RPC handlers.
The static read and write credentials above are local test fixtures. For signed-in users, retain
the generated browserAuthenticator from auth/service.ts; it verifies the session bearer with the
auth service. The low-level builder below installs its guards explicitly. Existing standalone
createScopeAuthorizer() consumers can still use their path rules.
For a deliberately public service, record the exact opt-out in its defineService() options:
// Explicit public policy (not a policy for the protected members service).
import type { ServiceAuthPolicy } from '@netscript/service/auth';
const auth = {
public: true,
reason: 'Public status service with no protected operations',
} satisfies ServiceAuthPolicy;
Pass that value as auth to defineService(router, { name: 'status', auth }). This disables the
service guards, so use it only when every operation may be anonymous. Find opted-out services by
searching their source for public: true. See
explicit service posture for the migration from
omitted auth.
If you are migrating older path rules, pass createScopeAuthorizer() as fallback. Contract
metadata always wins on disagreement. The fallback is consulted only for a matched procedure with
no access metadata; if no fallback rule matches, the adapter denies even when that fallback would
allow unmatched paths in standalone mode.
Step 3 — Implement the contract and layer the guard
Bind a handler to WorkspaceV1.members, then pass that router to the service builder. The handler
runs only for an authenticated, authorized caller. Its input and output are typed from the same
procedure that owns the access declaration:
// services/workspace/src/main.ts
import { os } from '@orpc/server';
import { createService, type ServiceHandlerContext } from '@netscript/service';
import { WorkspaceV1 } from '../../../contracts/versions/v1/workspace.contract.ts';
import { workspaceDb } from './db.ts'; // the chapter 3 workspace client
import { authenticator, authorizer } from './auth.ts';
const workspaceV1 = WorkspaceV1.$context<ServiceHandlerContext>();
const router = os.router({
members: workspaceV1.members.handler(async ({ input, context }) => {
// Auth is runtime-configured, so the public context type is intentionally optional.
// This procedure is guarded; narrow once before using the principal.
if (!context.principal) throw new Error('authenticated principal required');
const members = await workspaceDb.member.findMany({
where: { workspaceId: input.workspace },
take: input.limit,
skip: input.offset,
orderBy: { createdAt: 'asc' },
});
return {
workspace: input.workspace,
limit: input.limit,
offset: input.offset,
subject: context.principal.subject,
members,
};
}),
});
const app = createService(router, { name: 'workspace' })
.withRPC()
.withAuthn({ authenticator })
.withAuthz({ authorizer })
.build();
export { app };
The builder binds the contract policy to its actual REST and RPC mounts. If you customize
.withRPC({ apiPath, rpcPath, rpcAliases, deprecatedRpcRoutes }), one shared resolver uses those
effective paths for both authn and authz; you do not repeat them in the policy.
Step 4 — Call the guarded service with the generated typed client
After chapter 2 installs auth, the generated auth/bff.ts supplies createBrowserSessionClient()
and browserSessionContext(). Use them in the app's server-side request handler. The contract still
supplies the procedure input and output types:
// apps/dashboard/routes/examples/workspace/(_lib)/workspace-session.ts
import { WorkspaceContractV1 } from '@my-workspace/contracts';
import { browserSessionContext, createBrowserSessionClient } from '../../../../../../auth/bff.ts';
const workspaceClient = createBrowserSessionClient(WorkspaceContractV1, 'workspace', 'workspace');
export async function loadWorkspaceMembers(request: Request, workspace: string) {
return await workspaceClient.members(
{ workspace, limit: 20, offset: 0 },
{ context: browserSessionContext(request) },
);
}
// The same response boundary used by generated server-side session routes.
export async function handleWorkspaceMembers(request: Request, workspace: string) {
const headers = { 'cache-control': 'no-store' };
if (!browserSessionContext(request).accessToken) {
return Response.json({ authenticated: false }, { status: 401, headers });
}
try {
return Response.json(await loadWorkspaceMembers(request, workspace), { headers });
} catch (error) {
if (error instanceof Error && 'status' in error && (error.status === 401 || error.status === 403)) {
return Response.json({ authenticated: false }, { status: error.status, headers });
}
throw error;
}
}
Replace @my-workspace/contracts with your generated workspace's contracts package name. The helper
reads the HttpOnly session cookie on the server, then contributes its opaque bearer to the SDK call.
The client owns discovery, RPC serialization, transport, and error handling; no custom fetch or
Authorization header wrapper is needed. Never import auth/bff.ts into an island or return the
bearer to the browser. Invoke handleWorkspaceMembers() from the current request to return only
member data or a redacted authentication failure. It follows the generated session route's response
boundary: no session returns HTTP 401, an upstream authorization denial returns 403, and an
allowed call returns 200. Never retain the request's credential context between users.
The SDK credential reference shows the
public contribution API that the generated helper composes. A required procedure with no bearer
fails locally before transport; an anonymous caller that reaches the service receives 401. A valid
session that lacks workspace:read receives 403, and an authorized session returns the typed
members result. For the static fixture above, the equivalent SDK call uses read or write as its
accessToken; these strings are not production sessions.
This interactive call belongs to the app interface. Workers, sagas, and triggers run independently under the installation service identity, rather than depending on an open app or a user's session.
Test it out
The guard produces three distinct responses, each a real assertion in the framework's
builder-auth_test.ts. Drive them against the running service — start it under Aspire
(aspire start from aspire/), then call the route for team ws-1 (note: this tutorial assumes port 3001; in unpinned scaffolds, each project is allocated its own randomized high-range ports):
# No Authorization header -> authn rejects before the handler runs.
curl -i http://localhost:3001/api/workspace/ws-1/members
# HTTP/1.1 401 Unauthorized
# { "error": "UNAUTHORIZED", "message": "missing-credential" }
# 'write' authenticates (valid credential) but lacks workspace:read,
# so authz denies the scope-guarded route.
curl -i -H 'authorization: Bearer write' \
http://localhost:3001/api/workspace/ws-1/members
# HTTP/1.1 403 Forbidden
# { "error": "FORBIDDEN", "message": "authz.missing-scope:workspace:read" }
# 'read' carries workspace:read -> authn resolves the principal, authz allows it.
# The contract parses ?limit=2 into a typed page.
curl -i -H 'authorization: Bearer read' \
'http://localhost:3001/api/workspace/ws-1/members?limit=2'
The 200 body echoes the values the contract parsed — the typed workspace path param, the typed
limit/offset page, the authenticated subject, and the member rows from chapter 3:
{
"workspace": "ws-1",
"limit": 2,
"offset": 0,
"subject": "user:reader",
"members": [
{ "id": "mem_01", "workspaceId": "ws-1", "subject": "user:alice", "role": "member" }
]
}
An empty members array is still a 200 — it means the guard passed and the team simply has no rows
yet (provision one with the chapter 4 job). A 401 or 403
means the request never reached the query at all.
Verify your progress
The three curl calls above verify the service guard. The Step 4 app handler verifies the same
policy through the generated BFF client. The unauthenticated calls must fail closed, and the scoped
calls must succeed with typed member data:
- [ ]
contracts/versions/v1/workspace.contract.tsexportsWorkspaceContractV1with typed input, output, and.meta({ access: ... })requiringworkspace:read. - [ ]
services/workspace/src/auth.tsdefines the authenticator and constructs the authorizer fromWorkspaceContractV1. - [ ] The
workspaceservice implementsWorkspaceV1.members, calls.withRPC(), and applies.withAuthn()and.withAuthz(). - [ ] The handler reads typed
input.workspace,input.limit, andinput.offset; it does not parse the URL by hand. - [ ] An unauthenticated request returns
401 UNAUTHORIZED(missing-credential). - [ ]
Bearer writereturns403 FORBIDDEN(authz.missing-scope:workspace:read). - [ ]
Bearer readreturns200with a body carryingworkspace,limit, andsubject. - [ ]
GET /healthstill answers without a credential. - [ ] The Step 4
handleWorkspaceMembers()app response returns401without a session cookie,403with the static fixture cookie__Host-ns_session=write, and200with__Host-ns_session=read, through the generated typed BFF client and without customfetch. - [ ] Production calls use the current verified session's opaque bearer, rather than the static fixture credentials, and return only member data to the browser.
What you built
A guarded, typed members procedure: WorkspaceContractV1.members owns the HTTP path, typed input
and output, and required workspace:read scope; the service implements that procedure and opts into
its policy with createContractAuthorizer() — proven by a 401 for an anonymous request, a 403
for the wrong scope, and a 200 for a correctly-scoped one. That is the differentiator this chapter
exists to show: the URL, its typed data, and its authorization gate are not three hand-maintained
facts that can drift — they are projections of one contract, checked by the compiler and by the
framework's own auth tests.
You also saw the boundary: this is route-level scope authz, not org/role RBAC — the tenancy stays
yours.
Next Steps
- Ship it. Chapter 6 · Deploy runs the whole authenticated workspace locally under Aspire and takes it to production.
- Reuse the contract on the client. A typed SDK derived from
WorkspaceContractV1exposes the samemembersprocedure and metadata. If that procedure key is renamed, its contract-local metadata follows it and a stale SDK reference to the old key fails to type-check. - Go deeper on identity. The auth plugin guide covers resolving
real human sessions into the
Principalthis guard authorizes.