tRPC Procedure Authentication: Why AI-Generated Code Leaks User Data

AI coding tools scaffold tRPC routers without auth middleware. See the exact pattern attackers use to read other users' orders, invoices and messages — and how to fix it in one line.
tRPC became the second-most popular API convention in the Next.js ecosystem in 2025, right after REST. Its type safety is mesmerising — you call trpc.orders.getById.useQuery({ id }) from a component and get a typed response end to end.
This is also the exact feature that makes tRPC code generated by Cursor, Bolt or v0 dangerous. The type contract protects you from passing the wrong argument. It does not protect you from passing someone else's ID.
The procedure everyone ships
Here is the canonical router an AI tool writes when you ask for "orders backend":
// server/api/routers/orders.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { db } from "@/server/db";
export const ordersRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
return db.order.findUnique({ where: { id: input.id } });
}),
list: publicProcedure
.query(async () => {
return db.order.findMany({ orderBy: { createdAt: "desc" } });
}),
cancel: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input }) => {
return db.order.update({
where: { id: input.id },
data: { status: "cancelled" },
});
}),
});Zod validation? Check. Type-safe inputs? Check. Who is allowed to read or cancel the order? Unspecified. publicProcedure is exactly what it says — public.
The attack in three curl commands
tRPC over HTTP is a plain JSON-RPC envelope. An attacker doesn't need the official client. The same calls your React app makes are just POSTs that DevTools copies perfectly.
# 1. Dump every order in the system
curl https://app.example.com/api/trpc/orders.list
# 2. Read any specific order by UUID
curl "https://app.example.com/api/trpc/orders.getById?input=%7B%22id%22%3A%22<uuid>%22%7D"
# 3. Cancel someone else's order
curl -X POST https://app.example.com/api/trpc/orders.cancel \
-H "content-type: application/json" \
-d '{"id":"<victim-uuid>"}'Under the type-safe API lives a classic IDOR. Every row in the orders table is readable by anyone who knows its UUID — and UUIDs are trivial to collect: they're returned in orders.list, embedded in invoice PDFs, leaked by support emails, and often sequential in legacy seeds.
Why AI keeps shipping this
Look at what the prompt usually says:
"Create a tRPC router for orders with getById, list, and cancel procedures."
The prompt describes the shape of the API, not the trust boundary. Claude, GPT, Copilot and the rest of the pack oblige — they produce the procedures literally. Authorization is the developer's job, and by default there is no default.
Even the official tRPC docs show publicProcedure first; protectedProcedure appears pages later in an "auth" chapter that AI models treat as optional reading.
The one-line fix
tRPC has the right primitive; just use it everywhere. Define it once in server/trpc.ts:
import { TRPCError, initTRPC } from "@trpc/server";
import type { Session } from "next-auth";
type Context = { session: Session | null };
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, user: ctx.session.user } });
});Now rewrite the orders router with ownership baked in:
export const ordersRouter = router({
getById: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const order = await db.order.findFirst({
where: { id: input.id, userId: ctx.user.id },
});
if (!order) throw new TRPCError({ code: "NOT_FOUND" });
return order;
}),
list: protectedProcedure
.query(async ({ ctx }) => {
return db.order.findMany({
where: { userId: ctx.user.id },
orderBy: { createdAt: "desc" },
});
}),
cancel: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const { count } = await db.order.updateMany({
where: { id: input.id, userId: ctx.user.id },
data: { status: "cancelled" },
});
if (count === 0) throw new TRPCError({ code: "NOT_FOUND" });
return { ok: true };
}),
});Two things are now true:
- No unauthenticated caller reaches the resolver —
protectedProcedurethrows before the query runs. - Every query filters by
userId— even an authenticated user can't read another user's row.updateMany-with-count is important on mutations:updatewould throw Prisma's P2025 and a thoughtful attacker could still enumerate valid IDs from the timing of that error.
The ready-to-paste prompt
If you're vibe-coding, put this at the top of your tRPC prompts so the AI never drops back to publicProcedure:
System rule: every tRPC procedure in this project uses
protectedProcedure, notpublicProcedure, unless I explicitly say "public". Every query and mutation that touches a user-owned table MUST filter byctx.user.id. If you writeupdateorfindUniqueon a user-owned table, useupdateMany/findFirstwith auserIdguard and reject withNOT_FOUNDon zero rows.
How VibeWShield finds this
Our scanner walks /api/trpc/* batch endpoints, enumerates procedure names from the bundle, and replays each read with a second session. If orders.getById returns data for a UUID owned by user A when called with user B's session, that's a medium-severity IDOR. List procedures returning someone else's rows are high.
Scan your tRPC API for auth bypasses →
The check runs as part of our Deep Scan — no signup required.
Free security scan
Test your app for these vulnerabilities
VibeWShield automatically scans for everything covered in this article and more — 18 security checks in under 3 minutes.
Scan your app free