Secure tRPC APIs with JWT Middleware: A Type‑Safe, Reusable Pattern
Learn how to enforce JWT‑based authentication in tRPC using middleware. This guide walks through a concrete example, shows how context is injected, and discusses trade‑offs and best practices.
29 Jul 2025, 12:30 UTC

Why tRPC Middleware Is the Right Tool for Auth
When you expose a tRPC API you usually have many routers – user, admin, analytics, etc. Adding a guard on every resolver is tedious and error‑prone. tRPC’s middleware runs before any resolver, so you can centralise authentication logic, keep your resolvers focused, and guarantee that the context carries a verified user object.
Step 1 – Build a JWT‑Extracting Middleware
The middleware receives the request context, extracts the Authorization header, verifies the token, and attaches the decoded payload to the context. If the token is missing or invalid it throws a TRPCError with code UNAUTHORIZED, which tRPC translates to an HTTP 401.
Below is a minimal, type‑safe implementation for Node.js. Replace <JWT_SECRET> with your own secret or key.
import { createTRPCRouter, publicProcedure } from "@trpc/server";
import { TRPCError } from "@trpc/server";
import jwt from "jsonwebtoken";
// 1️⃣ Define the shape of the auth context
export type AuthContext = {
user: { id: string; email: string; role: string };
};
// 2️⃣ Middleware that verifies the JWT and injects `user`
export const authMiddleware = createTRPCRouter({}).middleware(async ({ ctx, next }) => {
const authHeader = ctx.headers?.authorization;
if (!authHeader) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing authorization header" });
}
const [, token] = authHeader.split(" ");
if (!token) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Malformed token" });
}
try {
const payload = jwt.verify(token, <JWT_SECRET>) as { id: string; email: string; role: string };
// 3️⃣ Attach the user to the context
return next({ ctx: { ...ctx, user: payload } });
} catch (err) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token" });
}
});
What Happens Under the Hood?
- The middleware receives a
ctxthat contains the raw request headers. - After verifying the JWT, it calls
nextwith an extended context that now includesuser. - If verification fails, a
TRPCErroris thrown immediately, stopping resolver execution.
Step 2 – Apply the Middleware to Routers
You can compose the middleware with any router. Below we create a public router (no auth) and a protected router that requires a logged‑in user.
// Public router – no auth required
export const publicRouter = createTRPCRouter({
ping: publicProcedure.query(() => "pong"),
});
// Protected router – auth middleware applied
export const protectedRouter = authMiddleware.createRouter({
me: publicProcedure.query(({ ctx }) => {
// ctx.user is guaranteed to exist here
return ctx.user;
}),
adminTask: publicProcedure.query(({ ctx }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin only" });
}
return "secret admin data";
}),
});
// Root router combining both
export const appRouter = createTRPCRouter({
public: publicRouter,
protected: protectedRouter,
});
Testing the Flow
Use tRPC’s createCaller to simulate requests. The following snippets illustrate a successful and a failed call.
import { appRouter } from "./router";
// 1️⃣ Successful call with a valid token
const callerWithAuth = appRouter.createCaller({
headers: { authorization: `Bearer <VALID_JWT>` },
});
const user = await callerWithAuth.protected.me(); // returns user object
// 2️⃣ Failure – missing header
const callerNoAuth = appRouter.createCaller({ headers: {} });
await callerNoAuth.protected.me(); // throws TRPCError with code UNAUTHORIZED
Trade‑offs & Best Practices
Performance
- The middleware runs on every request. Keep it lean – token verification is cheap, but avoid heavy DB lookups here.
- For operations that need fresh data (e.g., checking a revocation list), consider a separate authorization middleware or a lightweight cache.
Granular Role Checks
- tRPC does not provide built‑in role‑based access control. You can either add extra middleware layers or perform role checks inside each resolver, as shown in the
adminTaskexample. - For large codebases, a dedicated
roleMiddlewarethat accepts allowed roles can reduce duplication.
Immutability of Context
- Never mutate
ctxdirectly in middleware. Instead, merge a new object into the context to avoid accidental side effects that could leak state between requests.
Error Handling
- Always throw
TRPCErrorwith a specific code. Clients will receive the corresponding HTTP status (e.g.,UNAUTHORIZED→ 401,FORBIDDEN→ 403). - Do not expose internal error details in the message; keep it generic to avoid leaking sensitive data.
Actionable Next Steps
- Integrate the middleware into your existing routers and run a quick test suite to confirm that
ctx.useris available where expected. - Implement a role‑based middleware if you need fine‑grained permissions across multiple routers.
- Measure latency with a simple benchmark: call a resolver with and without the middleware and compare response times. The difference should be negligible for typical JWT verification.
- Add unit tests that cover both success and failure paths, ensuring that missing or invalid tokens result in the correct
TRPCErrorcodes. - Document the expected shape of the
userobject in your type definitions so that downstream developers can rely on type safety.
By centralising JWT verification in a tRPC middleware, you keep your API routes clean, enforce security consistently, and maintain full TypeScript type safety across your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.