This episode strengthens the tRPC API's defenses: protection against injection, overfetch, and payload abuse, rate limiting at the router or adapter level, as well as TLS, CSP, and input data sanitization best practices.

Security is not a final feature — it is a layer woven into the design. Episode 12 discusses the real threats that hit tRPC APIs and how to repel them: injection, overfetch, and payload abuse, then rate limiting, and closes with best practices such as TLS, CSP, and input sanitization.
Because tRPC exposes procedures directly, its attack surface differs from REST. Understanding these weak points lets you close them from the start.
The first threat is data interpreted as a command — for example a string that enters a database query. Zod restricts the shape of data, but sanitization remains the resolver's responsibility:
import { z } from "zod";
cari: t.procedure
.input(z.object({ q: z.string().min(1).max(100) }))
.query(({ input }) => {
// gunakan parameterized query, jangan interpolasi mentah
const escaped = input.q.replace(/[\s'"\\]/g, " ");
return users.filter((u) => u.nama.toLowerCase().includes(escaped.toLowerCase()));
}),The basic rule: validate the shape with zod (type, length, format), then query the database using parameterized statements or an ORM. Never build a query with raw interpolated strings — this prevents SQL/NoSQL injection.
Overfetch: the client requests more data than it needs. Because procedure output is determined by the resolver, avoid returning the full database object if the client only needs a few fields — use the output validation from episode 3 and project the required fields.
Underfetch is the opposite: many small round-trips. Combine related data in one procedure so the client does not call repeatedly:
userWithPosts: t.procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
const user = users.find((u) => u.id === input.id);
const posts = posts.filter((p) => p.authorId === input.id);
return { id: user.id, nama: user.nama, postCount: posts.length };
}),The userWithPosts procedure returns only the fields the client needs — one request, nothing excessive.
Very large input can consume memory and CPU. Limit it with zod (length, size) and limit the body size at the server level:
create: t.procedure
.input(
z.object({
judul: z.string().min(3).max(200),
konten: z.string().min(1).max(10_000),
tags: z.array(z.string()).max(10),
}),
)
.mutation(({ input }) => simpanPost(input)),In Express, limit the body parser:
app.use(express.json({ limit: "1mb" }));express.json({ limit: "1mb" }) rejects requests with a body larger than 1 megabyte before they enter the tRPC pipeline.
Rate limiting limits access frequency so the API is not flooded. Create simple middleware with in-memory storage:
const rateLimit = t.middleware(({ ctx, next, path }) => {
const key = `${ctx.user?.id ?? ctx.req?.ip}:${path}`;
const now = Date.now();
const hits = counters.get(key) ?? [];
const recent = hits.filter((t) => now - t < 60_000);
if (recent.length >= 30) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
}
recent.push(now);
counters.set(key, recent);
return next();
});
export const limitedProcedure = t.procedure.use(rateLimit);TRPCError({ code: "TOO_MANY_REQUESTS" }) maps to HTTP 429. An in-memory counter is enough for a single instance, but for multi-instance production use shared storage like Redis.
Rate limiting can also be installed at the HTTP layer before tRPC — for example Express middleware or an edge service like Cloudflare. The advantage: the limit applies to all paths without touching tRPC code, and it runs very close to the user.
Choose according to your needs: tRPC middleware gives per-user and per-procedure control; adapter/edge gives global protection with the best performance. Combining both is a common production approach.
All production traffic must go through HTTPS. Behind a proxy, make sure TLS is terminated at the edge and security headers are added:
app.use((req, res, next) => {
res.setHeader("Strict-Transport-Security", "max-age=63072000");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
next();
});Strict-Transport-Security forces the browser to always use HTTPS. The other headers prevent MIME-sniffing-based attacks and referrer leaks.
Content-Security-Policy restricts the sources of content a page may load — deflecting XSS. Make sure connect-src allows the tRPC endpoint and WebSocket:
Content-Security-Policy: default-src 'self'; connect-src 'self' wss://api.contoh.com;Notice wss://api.contoh.com in connect-src — without it, WebSocket connections from the frontend to the server will be blocked by the browser.
The best security is layered: zod schema validation rejects malformed input, sanitization cleans data before it enters storage, and authorization limits who can trigger procedures. Never rely on just one layer.
Warning
Memory-based rate limiting does not share state between server instances. In deployments with many instances, use Redis or an edge service so the limit stays consistent across all instances.
Episode 12 strengthens the tRPC API's defenses: repelling injection through validation and sanitization, avoiding overfetch and underfetch with projection and data combining, limiting payload abuse, and enforcing rate limiting at two levels plus TLS and CSP best practices.
Key takeaways:
connect-src that includes WebSocket.In the next episode, episode 13, we will discuss tRPC performance & optimization — optimizing batch requests and reducing round-trips, using dehydrate and hydration in SSR to minimize refetching, and server optimizations like caching, data loaders, and lazy routers.