tRPC Security Audit
Audit tRPC applications. tRPC procedures are RPC endpoints — every procedure is a public surface even if not documented.
When this skill applies
- Reviewing tRPC router and procedure definitions
- Auditing middleware chains (
use(...)) for auth
- Reviewing input/output schemas (Zod)
- Checking context creation for auth resolution
- Reviewing protected vs public procedure patterns
Workflow
Follow ../_shared/audit-workflow.md.
Phase 1: Stack detection
grep -E '"@trpc/(server|client|react-query|next)":' package.json
# Detect adapter
grep -nE 'fetchRequestHandler|createNextApiHandler|createExpressMiddleware|createHTTPServer' src/
Phase 2: Inventory
# Router definitions
grep -rn 'createTRPCRouter\|router(\|t\.router' src/ | head
# Procedures
grep -rnE 'publicProcedure|protectedProcedure|t\.procedure' src/ | head -50
# Middleware
grep -rn 't\.middleware\|\.use(' src/
# Context creation
grep -rn 'createContext\|createInnerTRPCContext' src/
# Input validation
grep -rn '\.input(' src/ | head -30
Phase 3: Detection — the checks
Context creation
The context is where auth resolution happens. Every procedure sees this.
- TRP-CTX-1
createContext reads auth token/cookie and resolves user once per request.
- TRP-CTX-2 Context doesn't leak secrets (DB password, internal IDs) — only resolved primitives needed by procedures.
- TRP-CTX-3 Context creation errors don't expose stack traces to client.
export const createContext = async ({ req }: CreateContextOptions) => {
const session = await getSessionFromRequest(req);
return {
db,
user: session?.user ?? null,
// NOT: req (raw request object — leaks too much), env secrets, etc.
};
};
Protected vs public procedures
- TRP-PROC-1
publicProcedure reserved for genuinely public endpoints (sign-up, login, public catalog). Everything else uses protectedProcedure.
- TRP-PROC-2
protectedProcedure defined via middleware that throws if ctx.user is null:const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
- TRP-PROC-3 No
publicProcedure doing user-specific reads or mutations. Audit each public procedure: would it make sense for an anonymous attacker to call?
Input validation
- TRP-IN-1 Every procedure has
.input(z.object({ ... })). Procedures without .input accept anything.
- TRP-IN-2 Input schemas use strict constraints (min/max length, format, enum). No
z.any() or z.unknown().
- TRP-IN-3
z.object({...}).strict() (rejects extras) or default strip — never .passthrough().
- TRP-IN-4 No
userId/tenantId in inputs — derive from ctx.user.
// BAD
export const updateProfile = protectedProcedure
.input(z.object({ userId: z.string(), name: z.string() }))
.mutation(({ input, ctx }) => {
return ctx.db.user.update({ where: { id: input.userId }, data: { name: input.name } });
// Attacker: { userId: 'someone-else', name: 'pwned' }
});
// GOOD
export const updateProfile = protectedProcedure
.input(z.object({ name: z.string().min(1).max(50) }))
.mutation(({ input, ctx }) => {
return ctx.db.user.update({ where: { id: ctx.user.id }, data: { name: input.name } });
});
Authorization (per-resource)
- TRP-AZ-1 Procedures touching specific resources verify ownership/role on that resource:
export const deletePost = protectedProcedure
.input(z.object({ postId: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.postId } });
if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
if (post.authorId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
return ctx.db.post.delete({ where: { id: input.postId } });
});
- TRP-AZ-2 Role checks via middleware:
adminProcedure = protectedProcedure.use(requireAdmin).
- TRP-AZ-3 No procedure trusts an input field to determine "whose data" to read/write.
Output filtering
- TRP-OUT-1 Procedures returning DB entities project to safe DTOs — no
passwordHash, mfaSecret, internal flags.
- TRP-OUT-2 Optional
.output(schema) validates response shape; useful to catch accidental field leakage in code review.
Error handling
- TRP-ERR-1
TRPCError codes used appropriately (UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, BAD_REQUEST 400).
- TRP-ERR-2 Error messages don't leak internal details. Custom
errorFormatter strips stack traces in production.
- TRP-ERR-3 Avoid revealing existence: return NOT_FOUND when user is not authorized to see a resource exists (vs. FORBIDDEN, which confirms existence).
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// strip stack in production
stack: process.env.NODE_ENV === 'production' ? undefined : shape.data.stack,
},
};
},
});
Batching
tRPC batches multiple procedure calls into one HTTP request by default.
- TRP-BAT-1 Batch size limits configured (
maxBatchSize on the link). Default has no hard limit at server; an attacker can send 1000s of calls per request.
- TRP-BAT-2 Rate limiting per user accounts for batched calls (count procedures, not HTTP requests).
Rate limiting
- TRP-RL-1 Procedures (especially mutations) have rate limiting. Middleware-based limiter using Redis/Upstash:
const rateLimited = t.middleware(async ({ ctx, next, path }) => {
const key = `rl:${ctx.user?.id ?? ctx.ip}:${path}`;
const { success } = await ratelimit.limit(key);
if (!success) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
return next();
});
CSRF / origin handling
When tRPC procedures are called via HTTP from a browser:
- TRP-CSRF-1 If using cookie auth, origin check applied on the handler (most adapters allow custom request inspection).
- TRP-CSRF-2 Bearer/JWT auth doesn't need CSRF.
Subscriptions
If using tRPC subscriptions (WebSocket):
- TRP-SUB-1 WebSocket upgrade authenticated. Subscription handlers re-check auth on emit.
- TRP-SUB-2 Subscription topics scoped to the user; no shared global topics carrying per-user data.
Logging
- TRP-LOG-1 Procedure input/output logging excludes sensitive fields (passwords, tokens, PII).
- TRP-LOG-2 Slow-procedure logging captures path + duration, not full payloads.
Dependencies
- TRP-DEP-1 tRPC v10 or v11. v9 is legacy.
- TRP-DEP-2 Companion packages (
@trpc/server, @trpc/client, @trpc/react-query) on same major version.
Phase 4: Triage
Critical: publicProcedure doing sensitive operations; procedure accepting userId from input; missing input schema; batch DoS surface.
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with TRP-.
1---2name: trpc-security3description: Security audit for tRPC applications covering procedure auth via middleware, input validation with Zod, protectedProcedure vs publicProcedure patterns, router composition, context creation, batching abuse, output sanitization, and tRPC-specific patterns across Next.js, Express, Fastify, and standalone adapters. Use this skill whenever the user mentions tRPC, @trpc/server, @trpc/client, @trpc/react-query, createTRPCRouter, protectedProcedure, publicProcedure, t.procedure, ctx, or asks "audit my tRPC app", "tRPC security", "tRPC middleware safe". Trigger when the codebase contains `@trpc/server` or `@trpc/client` in package.json.4---56# tRPC Security Audit78Audit tRPC applications. tRPC procedures are RPC endpoints — every procedure is a public surface even if not documented.910## When this skill applies1112- Reviewing tRPC router and procedure definitions13- Auditing middleware chains (`use(...)`) for auth14- Reviewing input/output schemas (Zod)15- Checking context creation for auth resolution16- Reviewing protected vs public procedure patterns1718## Workflow1920Follow `../_shared/audit-workflow.md`.2122### Phase 1: Stack detection2324```bash25grep -E '"@trpc/(server|client|react-query|next)":' package.json26# Detect adapter27grep -nE 'fetchRequestHandler|createNextApiHandler|createExpressMiddleware|createHTTPServer' src/28```2930### Phase 2: Inventory3132```bash33# Router definitions34grep -rn 'createTRPCRouter\|router(\|t\.router' src/ | head3536# Procedures37grep -rnE 'publicProcedure|protectedProcedure|t\.procedure' src/ | head -503839# Middleware40grep -rn 't\.middleware\|\.use(' src/4142# Context creation43grep -rn 'createContext\|createInnerTRPCContext' src/4445# Input validation46grep -rn '\.input(' src/ | head -3047```4849### Phase 3: Detection — the checks5051#### Context creation5253The context is where auth resolution happens. Every procedure sees this.5455- **TRP-CTX-1** `createContext` reads auth token/cookie and resolves user once per request.56- **TRP-CTX-2** Context doesn't leak secrets (DB password, internal IDs) — only resolved primitives needed by procedures.57- **TRP-CTX-3** Context creation errors don't expose stack traces to client.5859```ts60export const createContext = async ({ req }: CreateContextOptions) => {61 const session = await getSessionFromRequest(req);62 return {63 db,64 user: session?.user ?? null,65 // NOT: req (raw request object — leaks too much), env secrets, etc.66 };67};68```6970#### Protected vs public procedures7172- **TRP-PROC-1** `publicProcedure` reserved for genuinely public endpoints (sign-up, login, public catalog). Everything else uses `protectedProcedure`.73- **TRP-PROC-2** `protectedProcedure` defined via middleware that throws if `ctx.user` is null:74 ```ts75 const isAuthed = t.middleware(({ ctx, next }) => {76 if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });77 return next({ ctx: { ...ctx, user: ctx.user } });78 });79 export const protectedProcedure = t.procedure.use(isAuthed);80 ```81- **TRP-PROC-3** No `publicProcedure` doing user-specific reads or mutations. Audit each public procedure: would it make sense for an anonymous attacker to call?8283#### Input validation8485- **TRP-IN-1** Every procedure has `.input(z.object({ ... }))`. Procedures without `.input` accept anything.86- **TRP-IN-2** Input schemas use strict constraints (min/max length, format, enum). No `z.any()` or `z.unknown()`.87- **TRP-IN-3** `z.object({...}).strict()` (rejects extras) or default strip — never `.passthrough()`.88- **TRP-IN-4** No `userId`/`tenantId` in inputs — derive from `ctx.user`.8990```ts91// BAD92export const updateProfile = protectedProcedure93 .input(z.object({ userId: z.string(), name: z.string() }))94 .mutation(({ input, ctx }) => {95 return ctx.db.user.update({ where: { id: input.userId }, data: { name: input.name } });96 // Attacker: { userId: 'someone-else', name: 'pwned' }97 });9899// GOOD100export const updateProfile = protectedProcedure101 .input(z.object({ name: z.string().min(1).max(50) }))102 .mutation(({ input, ctx }) => {103 return ctx.db.user.update({ where: { id: ctx.user.id }, data: { name: input.name } });104 });105```106107#### Authorization (per-resource)108109- **TRP-AZ-1** Procedures touching specific resources verify ownership/role on that resource:110 ```ts111 export const deletePost = protectedProcedure112 .input(z.object({ postId: z.string().uuid() }))113 .mutation(async ({ input, ctx }) => {114 const post = await ctx.db.post.findUnique({ where: { id: input.postId } });115 if (!post) throw new TRPCError({ code: 'NOT_FOUND' });116 if (post.authorId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });117 return ctx.db.post.delete({ where: { id: input.postId } });118 });119 ```120- **TRP-AZ-2** Role checks via middleware: `adminProcedure = protectedProcedure.use(requireAdmin)`.121- **TRP-AZ-3** No procedure trusts an input field to determine "whose data" to read/write.122123#### Output filtering124125- **TRP-OUT-1** Procedures returning DB entities project to safe DTOs — no `passwordHash`, `mfaSecret`, internal flags.126- **TRP-OUT-2** Optional `.output(schema)` validates response shape; useful to catch accidental field leakage in code review.127128#### Error handling129130- **TRP-ERR-1** `TRPCError` codes used appropriately (UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, BAD_REQUEST 400).131- **TRP-ERR-2** Error messages don't leak internal details. Custom `errorFormatter` strips stack traces in production.132- **TRP-ERR-3** Avoid revealing existence: return NOT_FOUND when user is not authorized to see a resource exists (vs. FORBIDDEN, which confirms existence).133134```ts135const t = initTRPC.context<Context>().create({136 errorFormatter({ shape, error }) {137 return {138 ...shape,139 data: {140 ...shape.data,141 // strip stack in production142 stack: process.env.NODE_ENV === 'production' ? undefined : shape.data.stack,143 },144 };145 },146});147```148149#### Batching150151tRPC batches multiple procedure calls into one HTTP request by default.152153- **TRP-BAT-1** Batch size limits configured (`maxBatchSize` on the link). Default has no hard limit at server; an attacker can send 1000s of calls per request.154- **TRP-BAT-2** Rate limiting per user accounts for batched calls (count procedures, not HTTP requests).155156#### Rate limiting157158- **TRP-RL-1** Procedures (especially mutations) have rate limiting. Middleware-based limiter using Redis/Upstash:159 ```ts160 const rateLimited = t.middleware(async ({ ctx, next, path }) => {161 const key = `rl:${ctx.user?.id ?? ctx.ip}:${path}`;162 const { success } = await ratelimit.limit(key);163 if (!success) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });164 return next();165 });166 ```167168#### CSRF / origin handling169170When tRPC procedures are called via HTTP from a browser:171- **TRP-CSRF-1** If using cookie auth, origin check applied on the handler (most adapters allow custom request inspection).172- **TRP-CSRF-2** Bearer/JWT auth doesn't need CSRF.173174#### Subscriptions175176If using tRPC subscriptions (WebSocket):177- **TRP-SUB-1** WebSocket upgrade authenticated. Subscription handlers re-check auth on emit.178- **TRP-SUB-2** Subscription topics scoped to the user; no shared global topics carrying per-user data.179180#### Logging181182- **TRP-LOG-1** Procedure input/output logging excludes sensitive fields (passwords, tokens, PII).183- **TRP-LOG-2** Slow-procedure logging captures path + duration, not full payloads.184185#### Dependencies186187- **TRP-DEP-1** tRPC v10 or v11. v9 is legacy.188- **TRP-DEP-2** Companion packages (`@trpc/server`, `@trpc/client`, `@trpc/react-query`) on same major version.189190### Phase 4: Triage191192Critical: `publicProcedure` doing sensitive operations; procedure accepting `userId` from input; missing input schema; batch DoS surface.193194### Phase 5: Report195196Use `../_shared/findings-schema.md`. Prefix IDs with `TRP-`.