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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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 authenticatedFORBIDDEN- User is authenticated but lacks permissionNOT_FOUND- Resource doesn't existBAD_REQUEST- Invalid inputINTERNAL_SERVER_ERROR- Server error
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
- Fail fast - Check authentication and authorization early
- Use appropriate procedures - Don't use
protectedProcedurewhenpublicProcedureis sufficient - Verify ownership - Always verify resource ownership before mutations
- Clear error messages - Provide helpful error messages (but don't leak sensitive info)
- Log access attempts - Log failed authorization attempts for security monitoring