Skip to main content
0.0.x

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.

  1. 1 · Scaffold
  2. 2 · Auth
  3. 3 · Workspace data
  4. 4 · Provision job
  5. 5 · Route authz
  6. 6 · Deploy

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 workspace service and its typed contracts from chapter 1.
  • The Member model and the workspaceDb client 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.

@netscript/contracts — procedure access metadata
NameTypeDescription
.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);
@netscript/service/auth — the route-authz surface
NameTypeDescription
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.ts exports WorkspaceContractV1 with typed input, output, and .meta({ access: ... }) requiring workspace:read.
  • [ ] services/workspace/src/auth.ts defines the authenticator and constructs the authorizer from WorkspaceContractV1.
  • [ ] The workspace service implements WorkspaceV1.members, calls .withRPC(), and applies .withAuthn() and .withAuthz().
  • [ ] The handler reads typed input.workspace, input.limit, and input.offset; it does not parse the URL by hand.
  • [ ] An unauthenticated request returns 401 UNAUTHORIZED (missing-credential).
  • [ ] Bearer write returns 403 FORBIDDEN (authz.missing-scope:workspace:read).
  • [ ] Bearer read returns 200 with a body carrying workspace, limit, and subject.
  • [ ] GET /health still answers without a credential.
  • [ ] The Step 4 handleWorkspaceMembers() app response returns 401 without a session cookie, 403 with the static fixture cookie __Host-ns_session=write, and 200 with __Host-ns_session=read, through the generated typed BFF client and without custom fetch.
  • [ ] 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 WorkspaceContractV1 exposes the same members procedure 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 Principal this guard authorizes.