Ahmed Fathallah
WritingAboutContact
Sign in
WritingAboutContact
Sign inSign up

Ahmed Fathallah

Engineering notes on type-safe full-stack development, modern back ends and automated delivery.

WritingAboutContactSitemap

© 2026 Ahmed Fathallah

    Laravel-Style Authorization Policies for tRPC Procedures

    October 11, 2026·8 min read

    Next.js
    TypeScript
    tRPC
    Prisma
    Laravel-Style Authorization Policies for tRPC Procedures

    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:

    1. Policies check permissions, not roles. hasPermission(user, "cohorts:manage") instead of user.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.
    2. 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:

    • action must be the name of a public method on that policy that returns Promise<boolean>,
    • the remaining arguments must match that method's parameters (after user),
    • protected helpers like hasPermission must 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 protectedProcedure middleware and narrow ctx.user there.
    • 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 authorize generic over the policy so action names and arguments are checked by the compiler.
    • Use scopes (Prisma where clauses) 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.

    Comments

    Related Articles

    How This Blog Ships to Production with Docker, GHCR and Caddy

    How This Blog Ships to Production with Docker, GHCR and Caddy

    The fully automated pipeline behind this site: a multi-stage Next.js Dockerfile with BuildKit secrets, immutable SHA-tagged images on GHCR, workflow_run deployments with instant rollbacks, and Caddy as a hardened reverse proxy.

    Docker
    Next.js
    DevOps
    CI/CD
    Validating Next.js Environment Variables with Zod at Build Time and Runtime

    Validating Next.js Environment Variables with Zod at Build Time and Runtime

    A build can pass and health checks stay green while production ships a placeholder value. How to validate env vars with Zod in a containerised Next.js app, why NEXT_PUBLIC_ values are frozen at build time, and where to fail fast.

    Docker
    Next.js
    TypeScript
    Zod
    PHP Backed Enums as the Single Source of Truth in a Laravel API

    PHP Backed Enums as the Single Source of Truth in a Laravel API

    Using PHP 8.1 backed enums so each set of statuses is defined once: string columns, Eloquent casts, Rule::enum validation, state transitions, value/label API responses and generated TypeScript types.

    PHP
    TypeScript
    Laravel