I Fixed a DoS Vulnerability in Formbricks — and Added a Second Layer Nobody Asked For

typescript dev.to

A story about picking up a security issue, going beyond the spec, and what defense-in-depth actually means in practice

The issue

Someone opened a GitHub issue on Formbricks pointing out that the userId parameter in the SDK had no length validation. Next.js's 4MB default body limit was the only thing standing between a bad actor and the server.

The fix suggested was straightforward: add .max(255) to the Zod schema. That's it.

I picked it up the same day. But as I dug in, I realized the schema fix alone wasn't enough.


Why 255?

Before writing a single line, I thought about what userIds actually look like in production:

  • UUIDs: 36 characters
  • Emails (RFC 5321 max): 254 characters
  • Custom IDs: typically tens to hundreds of characters

255 covers everything real. It rejects everything abusive. The number isn't arbitrary — it's the smallest limit that breaks nothing legitimate.


The schema fix (Layer 1)

The issue pointed at one schema. I found four that needed fixing:

// packages/types/displays.ts
userId: z.string().max(255, {
  message: "User ID cannot exceed 255 characters"
}).optional()

// packages/types/js.ts
userId: z.string().max(255)  // ZJsUserIdentifyInput
userId: z.string().max(255)  // ZJsPersonSyncParams
Enter fullscreen mode Exit fullscreen mode

This validates at the API boundary — if an oversized userId reaches the server, it gets rejected before touching the database.

But here's what bothered me: the payload still travels over the network first.


The SDK guard (Layer 2)

The Formbricks JS SDK runs in the browser. setUserId() is called client-side. If I only validate on the server, a 4MB string still gets serialized, sent over the network, and processed by Next.js before being rejected.

That's wasteful at best. At scale with many concurrent requests, it's a real resource drain.

So I added an early rejection guard directly in user.ts:

const MAX_USER_ID_LENGTH = 255;

if (userId.length > MAX_USER_ID_LENGTH) {
  logger.error(`UserId exceeds maximum length of ${MAX_USER_ID_LENGTH} characters`);
  return okVoid();
}
Enter fullscreen mode Exit fullscreen mode

This runs before updateQueue.updateUserId() is ever called. The oversized string never leaves the browser. No network call. No server processing. No database touch.

The issue didn't ask for this. But once I saw the attack surface clearly, the schema fix alone felt incomplete.


The test

I added a unit test to lock in this behavior:

test("should reject userId longer than 255 characters and not send updates", async () => {
  const longId = "a".repeat(256);
  const result = await setUserId(longId);

  expect(result.ok).toBe(true);
  expect(mockLogger.error).toHaveBeenCalledWith(
    "UserId exceeds maximum length of 255 characters"
  );
  expect(mockUpdateQueue.updateUserId).not.toHaveBeenCalled();
  expect(mockUpdateQueue.processUpdates).not.toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

The test verifies three things: the function returns cleanly, the error is logged, and the update queue is never triggered. Future refactors can't accidentally regress this silently.


What I learned

The schema fix was the correct answer to the issue as written. The SDK guard was the correct answer to the actual problem.

These are different things. Reading an issue description and reading the underlying risk are different skills. The description tells you what to change. The risk tells you why, and once you understand why, you often see that the suggested change is necessary but not sufficient.

Defense in depth isn't a fancy term. It just means: don't rely on a single check. If the client-side guard fails or gets bypassed somehow, the server-side schema catches it. If someone calls the API directly without the SDK, the schema catches it. Two independent layers, neither depending on the other.

The PR got merged. Matti left a note: "The additional validation makes sense."

That's the whole story.


Links

I'm a final year CS student graduating in 2026, looking for backend/infra roles. If this kind of thinking interests your team, I'd love to connect.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials