← All posts
·7 min read·GuardLayer

Supabase 22P02: invalid input syntax for uuid

SupabasePostgresValidationRLSNext.js

22P02 is Postgres's invalid_text_representation: a string that is not a valid UUID reached a uuid column or parameter. Nine times out of ten it is an empty string — "" — from an uncontrolled form field or an unset query param. Fix it by validating and coercing the value before the query, and never by casting the column to text.

The response is terse and gives you nothing to grep for in your own code:

{
  "code": "22P02",
  "details": null,
  "hint": null,
  "message": "invalid input syntax for type uuid: \"\""
}

That is the verbatim body from supabase/supabase#33163. Note what the message tells you and what it does not: it names the value Postgres choked on, and nothing about which column or which parameter. When the value is an empty string, the quotes are the only content in the message — which is why this one reads as a mystery at first glance.

What does "invalid input syntax for type uuid" mean in Supabase?

That your query sent text where Postgres expected a UUID, and the text does not parse as one. Postgres validates UUID literals strictly — 32 hex digits in the canonical dashed form (braces and a missing-dash form are also accepted, but nothing else is). It does not accept an empty string, and it does not silently treat one as null.

Four sources cover essentially every report:

1. An empty string from a form or query param. The big one. An uncontrolled <input> sends "", searchParams.get("id") returns "" for ?id=, or a state variable initialises as "" before the user picks anything. Then:

// id === "" → 22P02
await supabase.from("projects").select("*").eq("id", id);

2. The strings "undefined" or "null". Template interpolation of a missing value produces these as literal text — `/api/projects/${project?.id}` yields /api/projects/undefined — and they reach the database as five- or four-character strings, not as SQL NULL.

3. A slug or a number where a UUID belongs. A route that accepts /projects/[id] and receives /projects/my-first-project, because someone shipped friendly URLs against a uuid primary key.

4. An insert that omits the value instead of sending null. insert({ parent_id: form.parentId }) where form.parentId is "" fails, while parent_id: form.parentId || null succeeds. The column being nullable does not help; "" is a value, and it is an invalid one.

The fix: reject it at the edge, not in the database

Validate the shape before the value reaches a query, and coerce empty strings to null in one place:

import { z } from "zod";

const Body = z.object({
  projectId: z.string().uuid(),
  // optional uuid that may arrive as "" from a form
  parentId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable()),
});

export async function POST(request: Request) {
  const parsed = Body.safeParse(await request.json());
  if (!parsed.success) {
    return Response.json({ error: "Invalid request" }, { status: 400 });
  }
  // …
}

Two properties matter here beyond making the error go away. You return 400 instead of surfacing a database error, and you return your message rather than Postgres's. Both are worth having for reasons that have nothing to do with UUIDs.

For a page or route reading a dynamic segment, guard before querying:

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID.test(params.id)) notFound();

The wrong fix: casting the column to text

This is why a type error is worth a security post. The fastest way to silence 22P02 is to stop comparing UUIDs as UUIDs:

-- Makes the error go away. Also breaks your ownership check.
using (auth.uid()::text = user_id::text)

Now nothing errors — and nothing matches either, whenever the stored string is not byte-identical to the canonical form auth.uid() produces. Uppercase hex, a braced form, a stray space: all compare unequal as text while being the same UUID. The policy stops raising and starts returning zero rows, which people then debug by loosening the policy further. The full mechanics of that trap are in why auth.uid() = user_id fails on uuid vs text — the short version is: fix the column type, do not cast around it.

The same applies to .eq("id", id) in application code. If the answer to 22P02 is "compare as text", the comparison you were relying on for authorization is now string equality on data you do not control the formatting of.

What the error tells an attacker, and what it tells you

Two things worth separating.

The leak is small but real. A raw database error returned to the client confirms the parameter is a UUID column and that your input reached Postgres unfiltered. That is reconnaissance, not a breach — but there is no reason to hand it over. Log the Postgres error server-side; return a generic 400.

The signal is the valuable part. 22P02 in your logs is proof that client input reached a query without validation. Today it produced a type error on a well-typed column. The same path with a text column produces no error at all, which is exactly when unvalidated input becomes a real problem. Treat every 22P02 as a pointer to a route that needs a schema.

Here is the shape that generates them:

import { createClient } from "@/lib/supabase/server";

export async function POST(request: Request) {
  const body = await request.json();

  const supabase = await createClient();

  const { data } = await supabase
    .from("projects")
    .select("id, name, owner_id")
    .eq("id", body.projectId)
    .eq("owner_id", body.ownerId);

  return Response.json({ data });
}

A static scan can't know projectId is a UUID column, and GuardLayer doesn't flag this route: its API-route rule reports request input that reaches a dangerous sink (a whole-body write, an owner id on a write, a fetched URL, a file path), and a body value in a read filter isn't one. A schema at the edge is still the fix.

And look past the crash at the second bug in the same three lines: owner_id is filtered using a value the caller supplied. Pass someone else's owner id and the filter obligingly applies it. A filter is not an authorization check — the owner has to come from the verified session, and RLS has to be the thing that enforces it:

const { data: { user } } = await supabase.auth.getUser();
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });

const { data } = await supabase
  .from("projects")
  .select("id, name, owner_id")
  .eq("id", parsed.data.projectId)
  .eq("owner_id", user.id);

A 60-second self-check

# 1. Routes reading a body with no schema in the file
grep -rln "request.json()" app/ --include=route.ts | xargs grep -Ln "safeParse\|z\.object\|parse("

# 2. Client input used directly in a filter
grep -rnE "\.eq\(\s*[\"'][a-z_]+[\"']\s*,\s*(body|params|searchParams)" app/

# 3. Casts around a uuid comparison in policies
grep -rniE "auth\.uid\(\)::text|::text\s*=\s*auth\.uid" supabase/migrations/

Step 2 is the one that matters most: any owner_id, user_id or tenant_id filtered from caller input is an access-control bug wearing a type error's clothes. Step 3 should return nothing.

FAQ

What is Postgres error 22P02? invalid_text_representation — a string could not be parsed into the target type. With Supabase you almost always see it as invalid input syntax for type uuid, because UUID primary keys are the default and UUIDs are strictly validated.

Why does an empty string cause it? Because "" is a value, not an absence. Postgres tries to parse it as a UUID and fails. Send SQL NULL instead — value || null in JavaScript — for an optional UUID.

How do I stop "invalid input syntax for type uuid: undefined"? That is the literal five-character string "undefined" produced by interpolating a missing value into a URL or query. Guard the value before building the request rather than fixing it at the database.

Should I cast the column to text to avoid the error? No. Casting turns a loud type error into a silent comparison that can fail for values that are genuinely equal — and if the comparison was your ownership check, it now quietly returns nothing or, worse, matches when it should not.

Is 22P02 a security vulnerability? Not by itself. It is a reliable signal that client input reaches your queries without validation, and returning the raw error tells the caller a little about your schema. Both are worth fixing; neither is an immediate breach.

Can GuardLayer detect this? Not the error, and not the unvalidated read above. It flags related mistakes: RLS policies that are not scoped to the requesting user, tables without RLS, service-role keys reaching client code, and API routes that pass request input to a dangerous sink, such as a write whose owner id comes from the body. The runtime 22P02 itself is not visible to a static scan.

Scan your Next.js + Supabase app — free

GuardLayer runs 36 checks — exposed keys, missing RLS, over-permissive policies, unprotected Server Actions — with the exact fix inline. Connect GitHub and it checks every push too — free on public repos.

No signup, no card. Raw files aren’t kept; only the report is saved.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.