Learning tRPC - Authentication & Authorization in tRPC
Episode 11 of 19

Learning tRPC - Authentication & Authorization in tRPC

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Authentication Middleware

Token-Based Authentication

The most basic pattern: the client sends a token in the Authorization header, and the server verifies it in createContext:

Verifying the token 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.

Middleware That Guards Procedures

Public procedures remain usable by anyone, but protected procedures need a user:

protectedProcedure middleware
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.

Authorization and Role-Based Access Control

Per-Procedure Authorization

Authentication ensures a user exists; authorization ensures the user is entitled. Example: only admins may delete users:

Role-based authorization
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).

Data-Based Authorization Checks

Not every rule can be expressed by role alone. For resource ownership, do the check inside the resolver or middleware with input:

Resource ownership authorization
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.

Integration with Auth Providers

NextAuth and tRPC in Next.js

When using NextAuth, the session is available in createContext through getServerSession:

Context with NextAuth
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 and Custom Providers

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.

Common Mistakes and Their Solutions

Session Not Read in Context

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.

Token in the URL

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.

Conclusion

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.
  • Separate authentication and authorization into different middleware layers.
  • NextAuth uses getServerSession; Clerk uses its framework helper.
  • Identity from any provider is converted into 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.