This episode secures the tRPC API: token, session, or cookie-based authentication middleware, per-procedure authorization with role-based access control, as well as integration with NextAuth, Clerk, or a custom provider.

An API anyone can access is an API that is not secure. Episode 11 discusses two concepts that are often mixed up: authentication — proving who the user is — and authorization — determining what the user is allowed to do. In tRPC, both are implemented through middleware and context.
You will build authentication middleware based on tokens, sessions, and cookies, apply per-procedure RBAC, and connect tRPC with providers like NextAuth and Clerk.
The most basic pattern: the client sends a token in the Authorization header, and the server verifies it in createContext:
import { initTRPC, TRPCError } from "@trpc/server";
const verifyToken = async (token: string) => {
// decode JWT atau panggil provider auth
const payload = await jwt.verify(token, SECRET);
return { id: payload.sub, role: payload.role };
};
export const t = initTRPC.context<Context>().create({
createContext: async ({ req }) => {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return { user: null };
const user = await verifyToken(token);
return { user };
},
});Here the context reads the token once per request. createContext runs the verification before any procedure runs, so ctx.user is available in all middleware and resolvers.
Public procedures remain usable by anyone, but protected procedures need a user:
export const protectedProcedure = t.procedure.use(
t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, userId: ctx.user.id } });
}),
);protectedProcedure.use(...) throws UNAUTHORIZED (HTTP 401) if the user is absent, then forwards ctx.userId to the resolver. This division keeps procedure definitions clear: publicProcedure for open endpoints, protectedProcedure for protected ones.
Authentication ensures a user exists; authorization ensures the user is entitled. Example: only admins may delete users:
export const adminProcedure = protectedProcedure.use(
t.middleware(({ ctx, next }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next();
}),
);
user: t.router({
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(({ ctx, input }) => hapusUser(input.id, ctx.user.id)),
}),adminProcedure throws FORBIDDEN (HTTP 403) for non-admin users — HTTP 403 correctly distinguishes "not logged in" (401) from "not allowed" (403).
Not every rule can be expressed by role alone. For resource ownership, do the check inside the resolver or middleware with input:
edit: protectedProcedure
.input(z.object({ postId: z.number(), judul: z.string() }))
.mutation(({ ctx, input }) => {
const post = cariPost(input.postId);
if (!post || post.authorId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
post.judul = input.judul;
return post;
}),This pattern matters: a user may only edit posts they own, not just any "logged in" user. Combining role middleware + ownership checks results in complete authorization.
When using NextAuth, the session is available in createContext through getServerSession:
import { getServerSession } from "next-auth";
export const createContext = async (opts: { req: NextRequest }) => {
const session = await getServerSession(authOptions);
return {
...opts,
session,
user: session?.user ?? null,
};
};getServerSession(authOptions) securely fetches the session server-side. The protectedProcedure middleware then reads ctx.user from the session — exactly the same pattern as the token, only the data source differs.
Clerk provides similar helpers for the frameworks it supports, for example reading the user from a request. The principle is always the same: convert the identity from the provider into ctx.user, then let tRPC middleware enforce the rules. For a custom provider, simply implement verifyToken like the first example and swap the provider secret with your own.
Info
Separate authentication and authorization into two middleware layers. Authentication sets ctx.user; authorization checks ctx.user. With this separation, authorization rules can change without touching the login process.
If ctx.session is always null, check the provider order: trpc.Provider must be inside the SessionProvider (NextAuth) or the Clerk provider. The context reads from the request when a procedure is called, so the auth provider must wrap the application first.
Sending a token in the query string is a bad habit we already discussed in episode 10. Always use the Authorization header, and make sure httpBatchLink does not spill the token into proxy logs.
Episode 11 secures your API: token, session, or cookie-based authentication through createContext and middleware, per-procedure authorization with RBAC and ownership checks, as well as smooth integration with NextAuth, Clerk, and custom providers.
Key takeaways:
createContext is the ideal point to verify identity.protectedProcedure enforces the presence of a user with code 401.adminProcedure and ownership checks enforce authorization with 403.getServerSession; Clerk uses its framework helper.ctx.user.In the next episode, episode 12, we will discuss API security, rate limiting & best practices — protection against injection, overfetch, and payload abuse, rate limiting at the router or adapter level, as well as TLS, CSP, and input sanitization best practices.