General
tRPC

Protect Endpoint

Learn how to protect tRPC endpoints with authentication and authorization.

The starter kit provides several base procedures for protecting endpoints. Choose the appropriate procedure based on your security requirements.

Available Procedures

Public Procedure

No authentication required. Use for public endpoints:

trpc/routers/public.ts
import { createTRPCRouter, publicProcedure } from '@/trpc/init';

export const publicRouter = createTRPCRouter({
  health: publicProcedure.query(() => {
    return { status: 'ok', timestamp: new Date() };
  })
});

Protected Procedure

Requires a valid user session. The session and user are available in ctx:

trpc/routers/user.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';

export const userRouter = createTRPCRouter({
  getProfile: protectedProcedure.query(async ({ ctx }) => {
    // ctx.user and ctx.session are guaranteed to exist
    return ctx.user;
  })
});

Protected Admin Procedure

Requires authentication AND admin role:

trpc/routers/admin.ts
import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init';

export const adminRouter = createTRPCRouter({
  getAllUsers: protectedAdminProcedure.query(async ({ ctx }) => {
    // ctx.user.role is guaranteed to be "admin"
    return await getAllUsers();
  })
});

Protected Organization Procedure

Requires authentication AND an active organization. The organization is available in ctx:

trpc/routers/organization.ts
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';

export const organizationRouter = createTRPCRouter({
  getData: protectedOrganizationProcedure.query(async ({ ctx }) => {
    // ctx.organization is guaranteed to exist
    // ctx.membership contains the user's role in the organization
    return await getOrganizationData(ctx.organization.id);
  })
});

Custom Authorization

Role-Based Access

Check user roles within a procedure:

trpc/routers/example.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';

export const exampleRouter = createTRPCRouter({
  adminOnly: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'admin') {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'Admin access required'
      });
    }

    return { data: 'admin data' };
  })
});

Resource Ownership

Verify the user owns the resource:

trpc/routers/posts.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { protectedProcedure } from "@/trpc/init";
import { db } from "@/lib/db";
import { postTable } from "@/lib/db/schema";

update: protectedProcedure
  .input(z.object({ id: z.string(), title: z.string() }))
  .mutation(async ({ input, ctx }) => {
    const [post] = await db
      .select()
      .from(postTable)
      .where(eq(postTable.id, input.id))
      .limit(1);

    if (!post) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "Post not found"
      });
    }

    // Check ownership
    if (post.authorId !== ctx.user.id) {
      throw new TRPCError({
        code: "FORBIDDEN",
        message: "You can only edit your own posts",
      });
    }

    // Update post
    const [updatedPost] = await db
      .update(postTable)
      .set({ title: input.title })
      .where(eq(postTable.id, input.id))
      .returning();

    return updatedPost;
  }),

Organization Membership

The protectedOrganizationProcedure automatically verifies organization membership. For additional checks:

trpc/routers/organization.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { protectedOrganizationProcedure } from "@/trpc/init";
import { assertUserIsOrgMember } from "@/lib/auth/server";

getData: protectedOrganizationProcedure
  .input(z.object({ organizationId: z.string() }))
  .query(async ({ input, ctx }) => {
    // Verify user is member (if different from active org)
    if (input.organizationId !== ctx.organization.id) {
      await assertUserIsOrgMember(input.organizationId, ctx.user.id);
    }

    return await getData(input.organizationId);
  }),

Plan-Based Access

Check if organization has required plan:

trpc/routers/premium.ts
import { TRPCError } from "@trpc/server";
import { protectedOrganizationProcedure } from "@/trpc/init";
import { requirePaidPlan, hasSpecificPlan } from "@/lib/billing";

premiumFeature: protectedOrganizationProcedure.query(async ({ ctx }) => {
  // Option 1: Throw error if no paid plan
  await requirePaidPlan(ctx.organization.id);

  // Option 2: Check for specific plan (doesn't throw)
  const hasProPlan = await hasSpecificPlan(ctx.organization.id, "pro");

  if (!hasProPlan) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "This feature requires a Pro plan",
    });
  }

  return { data: "premium content" };
}),

Creating Custom Procedures

You can create custom procedures for common authorization patterns:

trpc/procedures.ts
import {
  protectedOrganizationProcedure,
  protectedProcedure
} from '@/trpc/init';
import { TRPCError } from '@trpc/server';

/**
 * Procedure that requires user to have completed onboarding
 */
export const onboardedProcedure = protectedProcedure.use(
  async ({ ctx, next }) => {
    if (!ctx.user.onboardingComplete) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'Please complete onboarding first'
      });
    }

    return next({ ctx });
  }
);

/**
 * Procedure that requires organization admin role
 */
export const organizationAdminProcedure = protectedOrganizationProcedure.use(
  async ({ ctx, next }) => {
    const isAdmin =
      ctx.membership.role === 'admin' || ctx.membership.role === 'owner';

    if (!isAdmin) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'Organization admin access required'
      });
    }

    return next({ ctx });
  }
);

Usage:

trpc/routers/example.ts
import { createTRPCRouter } from '@/trpc/init';
import {
  onboardedProcedure,
  organizationAdminProcedure
} from '@/trpc/procedures';
import { z } from 'zod';

export const exampleRouter = createTRPCRouter({
  // Requires onboarding
  getDashboard: onboardedProcedure.query(async ({ ctx }) => {
    return await getDashboardData(ctx.user.id);
  }),

  // Requires org admin
  updateSettings: organizationAdminProcedure
    .input(z.object({ settings: z.object({}) }))
    .mutation(async ({ input, ctx }) => {
      return await updateOrgSettings(ctx.organization.id, input.settings);
    })
});

Error Codes

Use appropriate TRPC error codes:

  • UNAUTHORIZED - User is not authenticated
  • FORBIDDEN - User is authenticated but lacks permission
  • NOT_FOUND - Resource doesn't exist
  • BAD_REQUEST - Invalid input
  • INTERNAL_SERVER_ERROR - Server error
trpc/routers/example.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';

export const exampleRouter = createTRPCRouter({
  getResource: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const resource = await getResource(input.id);

      if (!resource) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Resource not found'
        });
      }

      // Check access
      if (!hasAccess(resource, ctx.user)) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: "You don't have access to this resource"
        });
      }

      return resource;
    })
});

Best Practices

  1. Fail fast - Check authentication and authorization early
  2. Use appropriate procedures - Don't use protectedProcedure when publicProcedure is sufficient
  3. Verify ownership - Always verify resource ownership before mutations
  4. Clear error messages - Provide helpful error messages (but don't leak sensitive info)
  5. Log access attempts - Log failed authorization attempts for security monitoring