tRPC gives you end-to-end type safety for data: the client knows exactly what a procedure takes and returns. It says nothing about who is allowed to call it. Every tRPC codebase I've seen starts with a protectedProcedure that checks "is someone logged in?" and then grows ad-hoc if (ctx.user.role !== "ADMIN") checks scattered across routers.
I spend a lot of time in Laravel as well as TypeScript, and Laravel's policies are one of the things I miss most when I switch: one class per model, one method per action, and a single authorize() call in the controller. This post shows how I brought that pattern to a Next.js + tRPC + Prisma app, and then how to make it properly type-safe, which the Laravel version can't be.
Layer 1: authentication in middleware
Start with the usual tRPC setup. The context resolves the session user (with their role and flattened permissions), and a middleware turns "maybe a user" into "definitely a user":
// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "./context";
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to access this resource",
});
}
if (!ctx.user.role) {
throw new TRPCError({ code: "FORBIDDEN", message: "User role not found" });
}
return next({
ctx: {
...ctx,
user: ctx.user, // narrowed: no longer nullable downstream
},
});
});
The next({ ctx: { user: ctx.user } }) line is easy to miss but important. Passing the narrowed value forward is what makes ctx.user non-nullable in every procedure built on protectedProcedure, so you don't need ctx.user! everywhere.
That covers authentication. Authorization (may this user do this thing to this record?) depends on the procedure and usually on the record itself, so it doesn't belong in a generic middleware.
Layer 2: policies
A policy is a class with one async method per action. Each method takes the user first and, optionally, the resource:
// server/policies/BasePolicy.ts
import type { UserWithRole } from "~/lib/types";
export abstract class BasePolicy {
protected hasPermission(user: UserWithRole, permission: string) {
return user.permissions.includes(permission);
}
protected hasAnyPermission(user: UserWithRole, permissions: string[]) {
return permissions.some((p) => user.permissions.includes(p));
}
protected isOwner(userId: string, resourceUserId: string) {
return userId === resourceUserId;
}
protected hasPermissionAndOwnership(
user: UserWithRole,
permission: string,
resourceUserId: string
) {
return (
this.hasPermission(user, permission) &&
this.isOwner(user.id, resourceUserId)
);
}
}
// server/policies/CohortPolicy.ts
import type { Cohort } from "@prisma/client";
import { Permissions } from "~/lib/permissions";
import { BasePolicy } from "./BasePolicy";
import type { UserWithRole } from "~/lib/types";
export class CohortPolicy extends BasePolicy {
async view(user: UserWithRole) {
return this.hasPermission(user, Permissions.COHORTS_VIEW);
}
async create(user: UserWithRole) {
return this.hasPermission(user, Permissions.COHORTS_MANAGE);
}
// Instructors can only edit their own cohorts.
async update(user: UserWithRole, cohort: Cohort) {
return this.hasPermissionAndOwnership(
user,
Permissions.COHORTS_MANAGE,
cohort.instructorId
);
}
async delete(user: UserWithRole, cohort: Cohort) {
return this.hasPermissionAndOwnership(
user,
Permissions.COHORTS_MANAGE,
cohort.instructorId
);
}
}
Two design decisions are doing the heavy lifting here:
- Policies check permissions, not roles.
hasPermission(user, "cohorts:manage")instead ofuser.role.name === "INSTRUCTOR". Roles are a bag of permissions stored in the database, so "let teaching assistants manage cohorts too" becomes a data change rather than a code change. - Helpers are
protected. Only the action methods are public. That matters later, when we make the action name type-safe.
Layer 3: one authorize call per procedure
A first version of authorize looks a lot like Laravel's: pass the policy, the action name as a string, and any arguments.
export async function authorize(
user: UserWithRole,
policy: BasePolicy,
action: string,
...args: unknown[]
) {
const method = (policy as any)[action];
if (typeof method !== "function") {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Policy method ${action} not found`,
});
}
if (!(await method.call(policy, user, ...args))) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to perform this action",
});
}
}
Bind it onto the context in protectedProcedure so procedures don't have to pass the user every time:
return next({
ctx: {
...ctx,
user: ctx.user,
authorize: (policy, action, ...args) =>
authorize(ctx.user, policy, action, ...args),
},
});
A procedure then reads top to bottom as load, authorize, act:
update: protectedProcedure
.input(z.object({ id: z.string(), name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const cohort = await prisma.cohort.findUnique({ where: { id: input.id } });
if (!cohort) {
throw new TRPCError({ code: "NOT_FOUND", message: "Cohort not found" });
}
await ctx.authorize(policies.cohort, "update", cohort);
return prisma.cohort.update({
where: { id: cohort.id },
data: { name: input.name },
});
}),
This works, and it's already a big improvement over inline role checks. But look at "update". It's a string. A typo ("udpate") compiles fine and fails at runtime with a 500. Forget to pass cohort and the policy receives undefined, then throws on cohort.instructorId. In Laravel that's the best you can do. In TypeScript we can do better.
Making authorize type-safe
We want three things from the compiler:
actionmust be the name of a public method on that policy that returnsPromise<boolean>,- the remaining arguments must match that method's parameters (after
user), - protected helpers like
hasPermissionmust not be accepted as actions.
Two small type utilities get us there:
type PolicyCheck = (user: UserWithRole, ...args: any[]) => Promise<boolean>;
// Names of the methods on P that look like policy checks.
type PolicyAction<P> = {
[K in keyof P]: P[K] extends PolicyCheck ? K : never;
}[keyof P];
// The parameters of that method, minus the leading `user`.
type PolicyArgs<P, A extends keyof P> = P[A] extends (
user: UserWithRole,
...args: infer Args
) => Promise<boolean>
? Args
: never;
keyof P only includes public members, so the protected helpers drop out automatically. That's why making them protected mattered. The mapped type keeps only keys whose value is a policy check, and infer Args captures whatever comes after user.
The new signature:
export async function authorize<
P extends BasePolicy,
A extends PolicyAction<P>,
>(user: UserWithRole, policy: P, action: A, ...args: PolicyArgs<P, A>) {
const check = policy[action] as PolicyCheck;
if (!(await check.call(policy, user, ...args))) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to perform this action",
});
}
}
The runtime "method not found" branch is gone, because that case can no longer compile. Now the mistakes from before are caught in the editor:
await ctx.authorize(policies.cohort, "view"); // ✅
await ctx.authorize(policies.cohort, "update", cohort); // ✅
await ctx.authorize(policies.cohort, "udpate", cohort); // ❌ not a CohortPolicy action
await ctx.authorize(policies.cohort, "update"); // ❌ expected 1 more argument
await ctx.authorize(policies.cohort, "update", { id: 1 }); // ❌ not a Cohort
await ctx.authorize(policies.cohort, "hasPermission", "x"); // ❌ protected helper
You also get autocomplete on the action name, which turns out to be the most-used feature of the whole thing.
For the context-bound version, repeat the generics so inference still flows through:
authorize: <P extends BasePolicy, A extends PolicyAction<P>>(
policy: P,
action: A,
...args: PolicyArgs<P, A>
) => authorize(ctx.user, policy, action, ...args),
I register policy instances in a plain object (export const policies = { cohort: new CohortPolicy(), ... } as const) rather than looking them up by string. The string-keyed getPolicy("cohort") I started with has the same problem as the string action: it's one more place a typo becomes a runtime error.
Lists need scopes, not checks
Per-record checks work for update and delete. They don't work for list: loading every cohort and filtering in memory with await policy.view(user, cohort) is both slow and easy to paginate wrongly.
For lists, give the policy a method that returns a Prisma where clause instead of a boolean:
import type { Prisma } from "@prisma/client";
export class CohortPolicy extends BasePolicy {
// ...checks as before
scope(user: UserWithRole): Prisma.CohortWhereInput {
if (this.hasPermission(user, Permissions.COHORTS_VIEW_ALL)) {
return {};
}
return { instructorId: user.id };
}
}
list: protectedProcedure.query(async ({ ctx }) => {
await ctx.authorize(policies.cohort, "view");
return prisma.cohort.findMany({
where: policies.cohort.scope(ctx.user),
orderBy: { startDate: "desc" },
});
}),
The authorization rule and the query filter now live next to each other in the same class, so they're much less likely to drift apart. Note that scope isn't async and doesn't return a boolean, so PolicyAction correctly excludes it from the list of authorizable actions.
403 or 404?
When a user asks for a record they're not allowed to see, returning FORBIDDEN confirms that the record exists. For most internal tools that's fine. For anything where IDs are guessable and existence is sensitive, return NOT_FOUND from the procedure instead, or better, load the record through the scope (findFirst({ where: { id, ...policy.scope(user) } })) so unauthorized records are simply never found.
Testing
Policies are plain classes with no tRPC or Prisma dependency at call time, so they're trivial to unit test:
import { describe, expect, it } from "vitest";
const instructor = { id: "u1", permissions: ["cohorts:manage"], role: { name: "INSTRUCTOR" } };
describe("CohortPolicy.update", () => {
const policy = new CohortPolicy();
it("allows the owning instructor", async () => {
expect(await policy.update(instructor, { instructorId: "u1" } as Cohort)).toBe(true);
});
it("rejects other instructors", async () => {
expect(await policy.update(instructor, { instructorId: "u2" } as Cohort)).toBe(false);
});
});
That's the real payoff of pulling authorization out of procedures. The rules that matter most for security become the easiest code in the app to test.
Summary
- Keep authentication in a
protectedProceduremiddleware and narrowctx.userthere. - Put authorization in one policy class per resource: public async methods for actions, protected helpers for the building blocks.
- Check permissions, not role names.
- Make
authorizegeneric over the policy so action names and arguments are checked by the compiler. - Use scopes (Prisma
whereclauses) for lists, and consider loading single records through them to avoid leaking existence.
It's about 60 lines of infrastructure, and it makes a whole category of "forgot to check permissions" bugs a lot harder to write, because the check is one obvious line and the compiler watches its arguments.
