tRPC Security Guide for Vibe Coders

Share

TL;DR

tRPC provides end-to-end type safety but doesn't automatically handle security. Always validate input with Zod schemas on every procedure. Use middleware to enforce authentication before protected procedures. Implement rate limiting to prevent abuse. The type safety is for developer experience; security comes from proper validation and authentication.

Why tRPC Security Matters for Vibe Coding

tRPC is popular for building type-safe APIs without separate API definitions. When AI tools generate tRPC code, they often focus on the type safety features but miss critical security patterns. Type safety catches bugs at compile time; it doesn't prevent malicious input at runtime.

Common issues include missing input validation, unprotected procedures, and assuming type safety equals security.

Input Validation

Every procedure that accepts input must validate it with Zod:

Basic Input Validation

import { z } from 'zod';
import { router, publicProcedure } from './trpc';

export const userRouter = router({
  // SECURE: Input validated with Zod
  getUser: publicProcedure
    .input(z.object({
      id: z.string().uuid(),
    }))
    .query(async ({ input, ctx }) => {
      return ctx.db.user.findUnique({
        where: { id: input.id },
      });
    }),

  // SECURE: Complex validation
  createUser: publicProcedure
    .input(z.object({
      email: z.string().email().max(255),
      name: z.string().min(1).max(100),
      password: z.string().min(8).max(100)
        .regex(/[A-Z]/, 'Must contain uppercase')
        .regex(/[0-9]/, 'Must contain number'),
    }))
    .mutation(async ({ input, ctx }) => {
      const hashedPassword = await hash(input.password);
      return ctx.db.user.create({
        data: {
          email: input.email,
          name: input.name,
          passwordHash: hashedPassword,
        },
      });
    }),
});

Dangerous: Missing Input Validation

// DANGEROUS: No input validation
getUser: publicProcedure
  .query(async ({ ctx }) => {
    // What is ctx.input? Could be anything!
    const userId = ctx.input.id;
    return ctx.db.user.findUnique({ where: { id: userId } });
  }),

// DANGEROUS: Trusting TypeScript types at runtime
getUser: publicProcedure
  .input((val: unknown) => val as { id: string }) // NO validation!
  .query(async ({ input }) => {
    return ctx.db.user.findUnique({ where: { id: input.id } });
  }),

TypeScript types are erased at runtime. Without Zod validation, any input passes through.

Authentication Middleware

Create protected procedures that require authentication:

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;

// Middleware that enforces authentication
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in',
    });
  }

  return next({
    ctx: {
      ...ctx,
      // Narrow the type - user is guaranteed to exist
      user: ctx.session.user,
    },
  });
});

// Protected procedure - requires auth
export const protectedProcedure = t.procedure.use(isAuthenticated);

// Admin procedure - requires admin role
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }

  if (ctx.session.user.role !== 'admin') {
    throw new TRPCError({
      code: 'FORBIDDEN',
      message: 'Admin access required',
    });
  }

  return next({
    ctx: { ...ctx, user: ctx.session.user },
  });
});

export const adminProcedure = t.procedure.use(isAdmin);

Using Protected Procedures

export const postRouter = router({
  // Public - anyone can view published posts
  getPublished: publicProcedure
    .input(z.object({ limit: z.number().min(1).max(100).default(10) }))
    .query(async ({ input, ctx }) => {
      return ctx.db.post.findMany({
        where: { published: true },
        take: input.limit,
      });
    }),

  // Protected - only logged in users can create
  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1).max(10000),
    }))
    .mutation(async ({ input, ctx }) => {
      // ctx.user is guaranteed to exist
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),

  // Admin only - delete any post
  adminDelete: adminProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.delete({
        where: { id: input.id },
      });
    }),
});

Authorization Checks

Beyond authentication, verify users can access specific resources:

export const postRouter = router({
  // User can only update their own posts
  update: protectedProcedure
    .input(z.object({
      id: z.string().uuid(),
      title: z.string().min(1).max(200).optional(),
      content: z.string().min(1).max(10000).optional(),
    }))
    .mutation(async ({ input, ctx }) => {
      // First, check ownership
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
        select: { authorId: true },
      });

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

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

      // Now safe to update
      return ctx.db.post.update({
        where: { id: input.id },
        data: {
          ...(input.title && { title: input.title }),
          ...(input.content && { content: input.content }),
        },
      });
    }),
});

Rate Limiting

Prevent abuse with rate limiting middleware:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
  analytics: true,
});

const rateLimited = t.middleware(async ({ ctx, next }) => {
  const identifier = ctx.session?.user?.id || ctx.ip || 'anonymous';
  const { success, limit, reset, remaining } = await ratelimit.limit(identifier);

  if (!success) {
    throw new TRPCError({
      code: 'TOO_MANY_REQUESTS',
      message: `Rate limit exceeded. Try again in ${Math.ceil((reset - Date.now()) / 1000)} seconds`,
    });
  }

  return next();
});

// Apply to sensitive procedures
export const rateLimitedProcedure = publicProcedure.use(rateLimited);

// Usage
export const authRouter = router({
  login: rateLimitedProcedure
    .input(z.object({
      email: z.string().email(),
      password: z.string(),
    }))
    .mutation(async ({ input, ctx }) => {
      // Login logic - rate limited to prevent brute force
    }),
});

Sensitive Data Handling

Don't expose sensitive data in responses:

// DANGEROUS: Returning full user object
getUser: protectedProcedure
  .input(z.object({ id: z.string().uuid() }))
  .query(async ({ input, ctx }) => {
    return ctx.db.user.findUnique({
      where: { id: input.id },
    }); // Returns passwordHash, apiKey, etc!
  }),

// SECURE: Select only needed fields
getUser: protectedProcedure
  .input(z.object({ id: z.string().uuid() }))
  .query(async ({ input, ctx }) => {
    return ctx.db.user.findUnique({
      where: { id: input.id },
      select: {
        id: true,
        name: true,
        email: true,
        avatar: true,
        createdAt: true,
        // NOT passwordHash, NOT apiKey
      },
    });
  }),

// Or use Zod to strip sensitive fields from output
const PublicUserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
}).strip(); // Remove extra fields

getUser: protectedProcedure
  .input(z.object({ id: z.string().uuid() }))
  .output(PublicUserSchema)
  .query(async ({ input, ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: input.id },
    });
    return PublicUserSchema.parse(user);
  }),

tRPC Security Checklist

  • Every procedure with input uses Zod validation
  • Input schemas have reasonable limits (max length, min/max values)
  • Protected procedures use authentication middleware
  • Resource access includes authorization checks (ownership)
  • Sensitive procedures are rate limited
  • Error messages don't leak sensitive information
  • Database queries select only needed fields
  • Admin procedures verify admin role
  • Context creation validates session properly
  • CORS configured appropriately for the API

Error Handling

Don't leak sensitive information in errors:

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // In production, don't expose internal errors
        stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
      },
    };
  },
});

// In procedures, use specific error codes
export const userRouter = router({
  getUser: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input, ctx }) => {
      try {
        const user = await ctx.db.user.findUnique({
          where: { id: input.id },
        });

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

        return user;
      } catch (error) {
        // Log the real error
        console.error('Database error:', error);

        // Return generic error to client
        if (error instanceof TRPCError) throw error;

        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Something went wrong',
        });
      }
    }),
});

CORS Configuration

// For Next.js API routes
import { createNextApiHandler } from '@trpc/server/adapters/next';

export default createNextApiHandler({
  router: appRouter,
  createContext,
  onError: ({ error }) => {
    console.error('tRPC error:', error);
  },
  // Restrict CORS in production
  responseMeta() {
    return {
      headers: {
        'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '',
        'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    };
  },
});

Does TypeScript type safety mean my API is secure?

No. TypeScript types are erased at runtime. A malicious client can send any data they want. You must validate all input with Zod or another runtime validation library. Type safety improves developer experience but doesn't provide security.

Should every procedure have input validation?

Every procedure that accepts input should validate it. Even if you expect a simple string ID, validate it's actually a string and optionally that it's a valid UUID format. Procedures without input (like getting current user) don't need input validation.

How do I handle file uploads with tRPC?

tRPC doesn't natively support file uploads. Use a separate endpoint (like a presigned S3 URL) for file uploads, then pass the file URL to tRPC. Validate file types, sizes, and scan for malware on the upload endpoint.

Should I rate limit all procedures?

Focus rate limiting on sensitive operations: authentication, password reset, email sending, and expensive operations. Public read-only queries may need lighter rate limiting. Consider different limits for authenticated vs anonymous users.

Scan Your tRPC Project

Find missing input validation, unprotected procedures, and security issues before they reach production.

Start Free Scan
Tool & Platform Guides

tRPC Security Guide for Vibe Coders