AuthN and AuthZ with TRPC

Karthick Ragavendran
4 min readJan 30, 2024

This article dives into practical ways to secure your tRPC endpoints using middleware and private procedures within an Express environment. We’ll assume you’re familiar with the core concepts of authentication (AuthN) and authorization (AuthZ) and focus on the implementation details.


Attach the authorization token into the context. This context will get called on all requests so lets keep it light.

import { CreateExpressContextOptions } from '@trpc/server/adapters/express'

export const createTRPCContext = ({
}: CreateExpressContextOptions) => {
const header = req.headers.authorization
const token = header?.split(' ')[1]
// Bearer eylkdflk
return { req, res, token }

TRPC Instance

The ‘t’ in the below code is the Trpc instance and it acts as a gateway to various tRPC functionalities, including defining routes, procedures, and utilizing middleware.

import { initTRPC } from '@trpc/server'
import { createTRPCContext } from './context'

export const t = initTRPC.context<typeof createTRPCContext>().create()

Notice I included the context while creating this makes sure the context we access inside the procedure is typed with the token string.

Now we can take the token and verify it inside the queries and mutations but lets use middleware for that.


Lets create a isAuthed middleware. This is going to be used for both Authentication and Authorization of the requester.


  • Acts as a gatekeeper for procedures, enforcing authentication and authorization.
  • Verifies provided tokens and extracts user information.
  • Checks user roles against required roles for authorized access.
  • Adds uid to the context for use within procedures.


  • Wraps procedures to apply isAuthed middleware, ensuring only authenticated and authorized users can access them.
  • Accepts optional roles argument to specify required roles for access.
export const privateProcedure = (...roles: Role[]) =>


The privateProcedure is versatile to be used for creating authenticated routes and role based routes.

Also remember the ctx now has uid.

export const authRoutes = router({
users: privateProcedure('admin').query(({ctx}) => {
// Only admins can access this procedure.

// Context also contains the uid.
const { uid } = ctx

user: privateProcedure().query(() => {
// Only authenticated users can access this procedre.


  • Checks for a valid token in the context.
  • Verifies the token using jsonwebtoken.
  • Extracts user ID and calls authorizeUser for role-based authorization.
  • Adds user ID to the context for subsequent use.
import { TRPCError } from '@trpc/server'
import { t } from './trpc'
import { Role } from './types'
import { JwtPayload, verify } from 'jsonwebtoken'
import { authorizeUser } from './util'

export const isAuthed = (...roles: Role[]) =>
t.middleware(async (opts) => {
const { token } = opts.ctx
if (!token) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Token not found.',
console.log('token', token)
let uid

try {
const user = await verify(token, process.env.NEXTAUTH_SECRET || '')
uid = (user as JwtPayload).uid
console.log('uid', uid)
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Invalid token.',

await authorizeUser(uid, roles)

return{ ...opts, ctx: { ...opts.ctx, uid } })
  • authorizeUser: Compares user roles against required roles, throwing a TRPCError if access is not granted.
  • getUserRoles: Fetches user roles from the database based on user ID.
export const authorizeUser = async (
uid: string,
roles: Role[],
): Promise<void> => {
if (!roles || roles.length === 0) {
return // No specific roles required, access is granted

const userRoles = await getUserRoles(uid)

if (!userRoles.some((role) => roles.includes(role))) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'User does not have the required role(s).',

export const getUserRoles = async (uid: string): Promise<Role[]> => {
const [adminExists, managerExists] = await Promise.all([
prisma.admin.findUnique({ where: { uid } }),
prisma.manager.findUnique({ where: { uid } }),

const roles: Role[] = []
if (adminExists) roles.push('admin')
if (managerExists) roles.push('manager')

return roles

Row level permission


  • Enforces fine-grained access control at the data level.
  • Checks if the requesting user’s roles or ID match allowed roles or a list of allowed user IDs.
  • Throws a TRPCError if access is denied.

export const checkRowLevelPermission = async (
uid: string,
allowedUids: string | string[],
allowedRoles: Role[] = ['admin'],
) => {
const userRoles = await getUserRoles(uid)

if (userRoles?.some((role) => allowedRoles.includes(role))) {
return true

const uids =
typeof allowedUids === 'string'
? [allowedUids]
: allowedUids.filter(Boolean)

if (!uids.includes(uid)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not allowed to do this action.',


  • Call checkRowLevelPermission before accessing sensitive data or performing actions to ensure authorized access.
  • Provide the requesting user’s ID, allowed user IDs, and optional allowed roles.
export const authRoutes = router({
user: privateProcedure()
.query(async ({ ctx, input }) => {
const user = await prisma.user.findUnique({ where: { uid: input.uid } })
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND' })

checkRowLevelPermission(ctx.uid, user.uid)
// [] of uids as allowedUids.
// checkRowLevelPermission(ctx.uid, [user.uid, ...managerUids])

// Optional: [] of roles
// checkRowLevelPermission(ctx.uid, [user.uid, ...managerUids]], ['admin', 'manager'])
return user

Key Points

  • Middleware provides a flexible way to intercept and handle requests before reaching procedures.
  • Private procedures offer a convenient way to protect specific routes with authentication and authorization.
  • Utility functions like authorizeUser and getUserRoles help organize authorization logic.
  • Row level permissions enable granular control over data access within procedures.

Thanks for reading!



Karthick Ragavendran

Fullstack engineer | React, Typescript, Redux, JavaScript, UI, Storybook, CSS, UX, Cypress, CI/CD.