Scaling API Logic with tRPC Protected Procedures
Learn how to eliminate repetitive authentication boilerplate in tRPC by implementing Protected Procedures and Middleware for type-safe API security.
23 Aug 2026, 17:19 UTC

The Problem: Repetitive Auth Logic
In many API architectures, developers find themselves repeating the same authentication and validation checks at the start of every single endpoint. Whether it is checking for a session cookie, verifying a JWT, or ensuring a user has a specific role, this boilerplate obscures the actual business logic and increases the risk of a developer forgetting a security check on a new endpoint.
The goal is to move these cross-cutting concerns out of the resolver and into a reusable layer that guarantees a procedure cannot execute unless specific conditions are met.
The tRPC Procedure Pattern
tRPC solves this through the concept of Procedures. A procedure is essentially a definition of a single API endpoint. Instead of writing a raw function, you build a procedure by chaining modifiers. This allows you to create a base "public" procedure and derive "protected" versions from it.
The core of this system is the Middleware. Middleware functions wrap the resolver, allowing you to intercept the request, modify the context, or throw an error before the final logic ever runs. Because tRPC shares types between the server and client, the client knows exactly which procedures require authentication based on the types returned by the server.
Implementing Protected Procedures
To implement this, you first define your createContext function. This is where you extract headers or cookies and turn them into a usable user object. Then, you create a middleware that checks this context.
Worked Example: Auth Middleware
The following example assumes the use of Zod for input validation and a standard tRPC server setup (v10+). Run these configurations on your server-side API definition file.
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
// 1. Initialize tRPC
const t = initTRPC.context<{ user?: { id: string; role: string } }>().create();
// 2. Create a base public procedure
export const publicProcedure = t.procedure;
// 3. Create a reusable middleware for authentication
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to access this resource',
});
}
return next({
ctx: { user: ctx.user }, // Override context to guarantee user exists
});
});
// 4. Create a protected procedure
export const protectedProcedure = t.procedure.use(isAuthed);
// 5. Use it in a router
export const appRouter = t.router({
getSecretData: protectedProcedure
.input(z.object({ id: z.string() }))
.query(({ input, ctx }) => {
// 'ctx.user' is now guaranteed to exist and be typed
return { data: `Secret for ${ctx.user.id}: ${input.id}` };
}),
getPublicData: publicProcedure.query(() => {
return { data: 'This is public' };
}),
});
Verification and Risks
To verify this implementation, attempt to call getSecretData without a valid session header. The server should return a UNAUTHORIZED error code, and the resolver logic inside getSecretData should never be reached. On the client side, if you attempt to access a property on ctx.user in a publicProcedure, the TypeScript compiler will flag it as potentially undefined, forcing you to handle the null case or use a protectedProcedure.
Trade-offs and Limitations
While middleware simplifies the resolver, it introduces a layer of abstraction that can make debugging difficult if you chain too many modifiers. If a request fails, you must determine if it failed at the input validation stage, the authentication middleware, or the actual resolver logic.
Additionally, remember that tRPC is a TypeScript-to-TypeScript bridge. This pattern provides incredible developer velocity for full-stack TS apps, but it is not suitable for public-facing APIs intended for third-party consumption (e.g., a public REST API for mobile apps written in Swift or Kotlin), as those clients cannot share the tRPC type definitions.
Actionable Closing
Stop manually checking for users inside your resolvers. Define a publicProcedure for your open endpoints and a protectedProcedure for everything else. By shifting authentication to the middleware layer, you ensure that your business logic remains clean and your security boundaries are enforced by the type system itself.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.