Build Reusable Auth with tRPC’s initTRPC Middleware – A Practical Guide
tRPC’s initTRPC lets you build reusable authentication middleware and a request‑scoped context. This guide shows how to create public and protected procedure builders, merge routers, and handle auth errors—all while keeping TypeScript types intact.
01 Jul 2026, 00:53 UTC

Problem: Repeating Auth Logic Across Routers
When a tRPC API grows, you often end up copying the same authentication checks into every router. That duplication makes the code fragile: a change in the auth logic must be applied everywhere, and a forgotten check can expose a protected route.
Thesis: One‑Shot Middleware + Request‑Scoped Context
tRPC’s initTRPC lets you compose a chain of middleware that runs before any procedure. By creating a context that is built per HTTP request and by defining reusable publicProcedure and protectedProcedure builders, you can centralise authentication logic, keep routers lean, and preserve full TypeScript type inference.
1. initTRPC and Request‑Scoped Context
The entry point for a tRPC server is initTRPC. It accepts a createContext function that runs for every incoming request and returns the context object.
import { initTRPC } from "@trpc/server";
import type { inferAsyncReturnType } from "@trpc/server";
export const t = initTRPC.context<{ user?: User }>().create();
export type Context = inferAsyncReturnType<typeof createContext>;
export async function createContext({ req, res }: { req: Request; res: Response }): Promise<{ user?: User }> {
// Pull session token from headers, cookies, or whatever you use
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return { user: undefined };
const user = await verifyToken(token); // replace with your auth logic
return { user };
}
The context is passed to every procedure, so ctx.user is available without prop‑drilling.
Why Request‑Scoped?
Each HTTP request gets its own context instance. That means concurrent requests never share mutable state, and you can safely store per‑request data (e.g., a database client, a request ID, or a user object).
2. Building Reusable Auth Middleware
Middleware in tRPC is a function that receives the current context, the next handler, and can either modify the context, short‑circuit the call, or throw an error.
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new trpc.TRPCError({ code: "UNAUTHORIZED", message: "No user found" });
}
return next();
});
Because isAuthenticated is a middleware, you can apply it to any procedure or router. The key is that it runs *before* the procedure body, so the procedure can safely assume ctx.user is defined.
3. Public vs Protected Procedure Builders
Instead of adding isAuthenticated to every protected route manually, wrap t.procedure into two helpers:
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
Now, any router can choose the appropriate builder:
publicProcedure– no auth required.protectedProcedure– automatically checksctx.userbefore executing.
Router Merging
Large codebases often split routers by feature. With mergeRouters, you can combine them while keeping the type safety intact.
export const todoRouter = t.router({
getAll: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.todo.findMany({ where: { userId: ctx.user!.id } });
}),
create: protectedProcedure
.input(z.object({ title: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.todo.create({ data: { ...input, userId: ctx.user!.id } });
}),
});
export const appRouter = t.router({
todo: todoRouter,
public: publicRouter,
});
4. Concrete Worked Example
Assume a minimal Express server with tRPC adapter:
import express from "express";
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./routers";
import { createContext } from "./context";
const app = express();
app.use(
"/trpc",
trpcExpress.createExpressMiddleware({ router: appRouter, createContext })
);
app.listen(4000, () => console.log("Server listening on 4000"));
Client side (React + @trpc/client):
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "./routers";
const trpc = createTRPCReact<AppRouter>();
function TodoList() {
const { data, error } = trpc.todo.getAll.useQuery();
if (error) return {error.message}
;
return {data?.map(t => - {t.title}
)}
;
}
If the client sends a request without a valid Authorization header, the server throws a UNAUTHORIZED error, which the client receives as a TRPCClientError and can handle gracefully.
Trade‑offs and Limitations
- Middleware Order: The auth middleware must be applied before any procedure that depends on
ctx.user. If you forget, the procedure will run withundefinedand may crash. - Context Shape Changes: tRPC v10 introduced a new context signature. Mixing code from older tutorials can lead to type mismatches. Always check the
createContextsignature in the version you use. - Error Propagation: Errors thrown in middleware propagate as tRPC errors. If you need custom error handling, wrap the middleware call in a try/catch and re‑throw a
TRPCErrorwith the desired code. - Runtime vs Compile‑time Validation: The authentication check is runtime. Input validation still relies on Zod schemas attached to each procedure; auth does not replace schema validation.
Actionable Checklist
- Define
createContextto pull the session or token from the request. - Write an auth middleware that throws
TRPCError.UNAUTHORIZEDwhenctx.useris missing. - Create
publicProcedureandprotectedProcedurebuilders. - Use the appropriate builder in each router; merge routers where needed.
- Run the server and client; confirm that unauthenticated requests to protected routes return a 401‑style error.
- Keep your tRPC version up to date and review the migration notes for changes to
initTRPCand middleware API.
By centralising auth in a middleware chain and leveraging request‑scoped context, you reduce duplication, improve maintainability, and keep your API fully type‑safe from server to client.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.