Back to Blog
Security

Fixing "JWT expired" and "row-level security policy" Errors in Supabase

Codoric Team

Codoric Team

2026-07-255 min read
Fixing "JWT expired" and "row-level security policy" Errors in Supabase

Fixing "JWT expired" and "row-level security policy" Errors in Supabase

These two errors show up in almost every Supabase project at some point, usually well after the initial setup, when a session has been open for a while or a new insert path gets added. Both have a specific, fixable root cause — this covers what's actually happening in each case.

Error: "JWT expired"

What's happening: Supabase issues a short-lived access token (1 hour by default) alongside a longer-lived refresh token. This error means the access token's exp claim has passed and the client is still sending the old token on a request — PostgREST (which enforces the JWT's expiry itself) rejects it.

The most common cause: autoRefreshToken is disabled, missing, or not actually running — often because the Supabase client was instantiated in a context that doesn't stay alive long enough for the background refresh timer to fire (a serverless function, a script, or a client re-created on every render instead of once).

The fix for client-side apps:

import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: true,
  },
});

Create this client once, at module scope, not inside a component or function that re-runs — a new client instance loses whatever refresh timer the previous one had set up.

If you're seeing it in a Next.js App Router setup specifically: this is almost always a case of the server-side client (@supabase/ssr) not correctly reading/writing the refreshed session cookie. Verify the getAll/setAll cookie handlers are wired into every context that creates the client (Server Components, Server Actions, middleware) — a gap in just one of those means that context keeps using a stale token even after another part of the app refreshed it.

If it's happening in a long-running background job or script (not a browser): call supabase.auth.refreshSession() explicitly before the token is due to expire, or better, use the service role key for trusted server-side jobs instead of a user's access token — service role requests aren't subject to this expiry-driven flow the same way.

A defensive pattern worth adding regardless of the root cause: listen for the error and force a refresh + retry once, rather than surfacing it directly to the user:

const { data, error } = await supabase.from('projects').select('*');

if (error?.message.includes('JWT expired')) {
  await supabase.auth.refreshSession();
  // retry the request once
}

This doesn't fix a misconfigured client, but it prevents a transient expiry (a tab left open past the token's lifetime, right at the edge of the refresh window) from surfacing as a hard error to the user.

Error: "new row violates row-level security policy for table X"

What's happening: this is Postgres error code 42501, raised specifically when an INSERT or UPDATE is rejected by a WITH CHECK clause — the row being written doesn't satisfy the condition in the applicable policy. This is different from a SELECT being silently filtered to zero rows; an insert/update failing the check raises an actual error instead.

The most common cause: forgetting with check entirely, or writing one that doesn't match what the app actually sends. For example:

create policy "Users can insert their own projects"
  on public.projects for insert
  with check (auth.uid() = user_id);

This fails if the client's insert doesn't explicitly set user_id to the current user's ID — a very common mistake when relying on a database default instead:

-- If user_id has no default and the client doesn't set it, this check fails
create table public.projects (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users(id) not null,
  name text not null
);

Two ways to fix it, depending on where you want the responsibility to live:

  1. Set it explicitly on the client, matching what the policy expects:
const { data: { user } } = await supabase.auth.getUser();

await supabase.from('projects').insert({
  name: 'New Project',
  user_id: user.id,
});
  1. Or set it as a column default so the client never has to remember to include it, and simplify the policy to match:
alter table public.projects
  alter column user_id set default auth.uid();

Option 2 is generally safer for larger teams — it removes an easy-to-forget step from every insert call site, instead of relying on every developer remembering to set user_id correctly on every insert across the codebase.

Another common cause: the policy checks a condition that depends on a related table, and that relationship doesn't exist yet at insert time. For example, a policy that checks organization membership via a join, on a row being inserted into a brand new organization the user just created in the same transaction — if the membership row hasn't been committed yet when the policy check runs, the insert into the dependent table fails. The fix here is usually ordering: insert the organization, insert the membership row, then insert anything that depends on that membership being visible — not all in a single insert that assumes the membership already exists.

Debugging technique that saves the most time: temporarily run the exact insert as the postgres role (bypassing RLS) to confirm the data itself is valid, then run it via set role authenticated; set request.jwt.claims = '...' (or more simply, via the Supabase client with a real user session) to isolate whether the problem is the data or the policy. If it succeeds as postgres but fails as authenticated, the issue is definitely in the policy's with check clause, not in the data being sent.

The related error worth knowing: "permission denied for table X"

This is a different error from the RLS-specific one above — it means there's no GRANT for the authenticated (or anon) role on that table at all, independent of any RLS policy. Supabase grants SELECT, INSERT, UPDATE, DELETE to authenticated by default on tables created through the dashboard/migrations in the public schema, but a table created with unusual permissions, or in certain edge cases involving views, can end up without the expected grants. If adding a correct RLS policy doesn't resolve an error, check GRANT statements before assuming the policy itself is wrong — RLS policies only apply to operations the role already has a baseline grant for.

Both of these errors are, in a sense, the system working as designed — they're Postgres and PostgREST refusing to do something the current auth context isn't allowed to do. The fix is almost never "disable the check," it's identifying exactly which piece of context (an expired token, a missing column value, a missing grant) doesn't match what the policy or the client expects.

If you're stuck on a specific RLS or auth error in a real project, feel free to book a call — these are usually a fast diagnosis once we can see the actual policy and request together.