← All posts
·6 min read·GuardLayer

Fix 431 REQUEST_HEADER_FIELDS_TOO_LARGE on Vercel

SupabaseNext.jsVercelAuthCookies

431 REQUEST_HEADER_FIELDS_TOO_LARGE on a Supabase + Next.js app means your session cookie has outgrown the request header limit — usually because an OAuth login stuffed the provider's access and refresh tokens into it. Stop persisting provider_token in the session and request only the scopes you actually use.

This one is unusual because nothing in your code is wrong. Auth works, the login completes, and then some requests start returning a 431 that your application code never sees — the edge rejects the request before your function ever runs. And it gets worse over time rather than failing cleanly, which is why it usually reaches production before anyone notices.

@supabase/ssr issue #78 — "Azure oAuth leading to 431 REQUEST_HEADER_FIELDS_TOO_LARGE in NextJS on Vercel", still open. The reporter's app worked for over a week and then began failing progressively, eventually 431-ing even with minimal OAuth scopes. Their words: "It's quite odd that this seems to be getting worse over time."

Why an auth cookie gets big enough to break HTTP

Three things stack up.

1. Supabase puts the provider's tokens inside the session. When you call signInWithOAuth with scopes, the response from Google, Azure, or GitHub includes a provider_token and — if you asked for offline_access — a provider_refresh_token. Those live in the session object, and the session object is what gets serialized into the auth cookie. A Microsoft Entra access token with a few scopes is comfortably a couple of kilobytes on its own.

2. Browsers cap a single cookie at about 4 KB, so @supabase/ssr chunks. When the serialized session exceeds the limit, the library splits it across numbered cookies — sb-<project-ref>-auth-token.0, .1, .2, and so on. The chunk size is a constant in the library:

// @supabase/ssr — src/utils/chunker.ts
export const MAX_CHUNK_SIZE = 3180;

Chunking solves the per-cookie limit. It does nothing about the total limit, because every chunk is sent on every single request, and each one carries its own name, attributes, and separator overhead.

3. Next.js adds its own headers. The App Router sends next-router-state-tree (a URL-encoded description of the route tree) on client navigations and prefetches. It's not huge, but it lands on top of a cookie header that's already near the ceiling.

The result: a session that's fine at 4 KB right after signup drifts up as more provider data accumulates, and one day crosses the line. Node's own default cap on request headers is 16 KB, and edge platforms sit at or below that — so the failure arrives as a platform-level 431 with no application stack trace attached.

How do I fix a 431 error on Supabase OAuth?

Shrink what's in the cookie. In priority order:

Ask for fewer scopes. This is the highest-leverage change and takes thirty seconds. Every scope you request widens the token the provider hands back.

await supabase.auth.signInWithOAuth({
  provider: "azure",
  options: {
    // Only what you actually call. Drop offline_access unless you
    // genuinely need to act on the user's behalf while they're away.
    scopes: "email openid profile",
    redirectTo: `${origin}/auth/callback`,
  },
});

Stop carrying the provider token in the session. If you only need the provider token once — to read a profile, pull a calendar, fetch an avatar — consume it in your callback route and let it fall out of the session:

// app/auth/callback/route.ts
const { data, error } = await supabase.auth.exchangeCodeForSession(code);

if (data.session?.provider_token) {
  await fetchProfileFromProvider(data.session.provider_token);
  // Used and discarded — never persisted anywhere.
}

If you must keep it, put it in Postgres, not the cookie. This is the standard workaround, and it's where the fix quietly becomes a security decision. You're taking a long-lived credential for a third-party account and writing it into a table that Supabase's Data API will happily expose:

-- Moving the OAuth provider tokens out of the session cookie
-- and into Postgres, to get the request header back under the limit.
create table public.user_provider_tokens (
  user_id uuid primary key references auth.users (id) on delete cascade,
  provider text not null,
  provider_token text not null,
  provider_refresh_token text,
  expires_at timestamptz,
  created_at timestamptz not null default now()
);

That migration ships a table full of third-party refresh tokens with no Row Level Security on it. Here's what GuardLayer reports on exactly that file — live engine output, not a mockup:

guardlayer scan · supabase/migrations/20260824100000_provider_tokens.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20260824100000_provider_tokens.sql:3

    Table created without enabling RLS

    Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.

A table like this is worse than the average table created without RLS, because the rows aren't your data — they're bearer credentials for someone's Microsoft or Google account. Anyone holding the public anon key could read every one of them. The minimum correct version keeps it off the Data API entirely:

alter table public.user_provider_tokens enable row level security;

-- No policies for anon/authenticated at all: this table is server-only.
revoke all on public.user_provider_tokens from anon, authenticated;

With RLS enabled and no policies, every Data API read returns zero rows. Reach the table from a server route using the service role key, which is the one context where that's the right tool — provided the key never leaves the server.

Measure it before and after

The cookie header is directly observable, so don't guess:

// In the browser console, on a page where you're logged in.
document.cookie.length;                       // total bytes
document.cookie.split("; ")
  .filter((c) => c.includes("auth-token"))
  .map((c) => [c.split("=")[0], c.length]);   // per-chunk sizes

If you see .0, .1, .2 and beyond, or a total in the 8–12 KB range, you're on the edge of the cliff even if you haven't fallen off yet. A healthy Supabase session with no provider tokens is typically one or two chunks.

Clearing the site's cookies makes the error disappear until the user logs in again — useful for confirming the diagnosis, useless as a fix. If clearing cookies doesn't help, you may be looking at leftover chunk cookies from a previous session instead, which is a different bug with a similar smell.

FAQ

Is 431 a Supabase bug? No. It's HTTP working as designed: the server refuses a request whose headers exceed its limit. Supabase's contribution is storing provider tokens in the session by default, which is what pushes a normal app over the line.

Can I just raise the header limit? On your own Node server, yes — --max-http-header-size exists. On a managed platform you generally can't, and you shouldn't want to: a multi-kilobyte cookie is sent on every request, including static assets and prefetches, so it's a permanent latency tax on top of a fragility problem.

Why does it get worse over time instead of failing immediately? Provider data accumulates across re-authentications and scope changes, and stale chunk cookies can linger alongside fresh ones. The header grows monotonically until it crosses the limit.

Does this affect local development? Rarely. Local dev servers usually allow larger headers than an edge platform, so the same session that 431s in production works fine on localhost. That's the single most common reason this ships.

Is storing provider tokens in the database safe? Safer than a cookie, but only if you treat the table as secret material: RLS enabled, no anon or authenticated grants, service-role access only, and ideally encrypted at rest via Supabase Vault. A plain table with a permissive policy is a worse outcome than the 431 you were fixing.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

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