Centralizing Authentication in tRPC with Middleware: A Type-Safe Pattern
Use tRPC middleware to centralize authentication, enrich context with a typed user object, and keep resolvers clean—without losing type inference or adding measurable latency.
24 Nov 2025, 15:16 UTC

The Problem: Scattered Auth Checks Break Type Safety
When authentication logic lives inside individual resolvers, two things happen: you repeat the same token validation across dozens of procedures, and TypeScript loses track of the authenticated user shape. Each resolver either casts ctx.user as any or duplicates a type guard, and a missed check in one route becomes a security hole.
tRPC middleware solves this by running before any resolver, letting you validate once, enrich the context with a typed user object, and abort early with a proper TRPCError. The router composition stays clean, and downstream resolvers receive a fully inferred ctx.user without casts.
Middleware Signature and Context Enrichment
A tRPC middleware receives ctx, next, and the procedure's input. Returning await next({ ctx: { ...ctx, user } }) extends the context for the rest of the pipeline. Because the middleware runs inside the same request, the enriched context flows through router composition and route-level guards without extra wiring.
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ req: Request }>().create();
const authMiddleware = t.middleware(async ({ ctx, next }) => {
const token = ctx.req.headers.get('x-auth-token');
if (!token) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Missing token' });
}
// In practice, verify JWT or lookup session here
const user = { id: 'u-123', role: 'admin' as const };
return next({ ctx: { ...ctx, user } });
});
const authedProcedure = t.procedure.use(authMiddleware);
The authedProcedure now carries ctx.user with the exact shape { id: string; role: 'admin' }. Any resolver using it accesses ctx.user.role with full autocomplete and compile-time checking.
Scoping Middleware to Router Sections
Not every route needs the same guard. You can compose routers with different middleware stacks:
const publicRouter = t.router({
health: t.procedure.query(() => ({ ok: true })),
});
const adminRouter = t.router({
deleteUser: authedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// ctx.user is typed as { id: string; role: 'admin' }
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return { deleted: input.id };
}),
});
const appRouter = t.router({
public: publicRouter,
admin: adminRouter,
});
Requests to public.health skip auth entirely. Requests to admin.deleteUser pass through authMiddleware first, then the resolver sees a typed ctx.user. This keeps the global auth logic DRY while allowing fine-grained access control.
Worked Example: Verifying the Flow
To confirm the middleware aborts before the resolver runs, create a test that calls the procedure without a token:
// Run in a Vitest/Jest file with a test caller
import { appRouter } from './router';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
const client = createTRPCProxyClient<typeof appRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],
});
try {
await client.admin.deleteUser.mutate({ id: 'x' });
} catch (err) {
console.log(err.shape?.code); // 'UNAUTHORIZED'
// Resolver body never executed
}
With a valid header, the same call reaches the resolver and ctx.user.role is typed as 'admin'. Compile the project (tsc --noEmit) to verify no type errors appear.
Trade-offs and Limitations
- Blocking work in middleware: Heavy synchronous JWT verification or database lookups inside the middleware block the event loop. Offload to async functions or a dedicated auth service; keep the middleware lightweight.
- Deep middleware stacks obscure debugging: When five middlewares wrap a procedure, tracing which one mutated
ctxor threw becomes harder. Limit stacks to 2-3 layers and log middleware entry/exit in development. - Context type widening: If you attach
userin one middleware but another router doesn't use it, the base context type must includeuser?: Useror you'll get type errors on public routes. Define a base context with optional user and narrow in authed procedures.
Actionable Next Steps
- Add a single
authMiddlewarethat reads a header, throwsUNAUTHORIZEDon failure, and returnsnext({ ctx: { ...ctx, user } })on success. - Create
authedProcedure = t.procedure.use(authMiddleware)and migrate one protected route to use it. - Run
tsc --noEmitto confirmctx.useris inferred correctly in the resolver. - Benchmark a request with and without the middleware (e.g.,
wrk -t2 -c100 -d10s http://localhost:3000/trpc/admin.deleteUser). Expect <5 ms overhead. - If latency spikes, move token verification to an async helper or external service and keep the middleware as a thin wrapper.
Start small, verify types compile, and measure. The pattern scales cleanly as your router grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.