You ran a Replit Agent build, wired up Supabase, opened the mobile preview, and the first call to your database returns HTTP 403. The auth flow works, the SQL works in the editor, and the same code worked yesterday in the web preview. The fix is rarely one thing, so the first move is to identify which of four common causes is in play.
Short answer
A 403 from Supabase in a Replit Agent mobile build almost always traces to one of four problems: row level security is enabled on the table without any policies, the anon key is referenced by the wrong environment variable name, the JWT is missing or stale on the outgoing request, or an SSL or CORS misroute on Replit's preview domain prevents the request from reaching Supabase with the right headers. Check the response body for a PostgreSQL 42501 code first, then work the four causes in order.
What you should know
- A bare 403 with PostgreSQL code 42501 means RLS, not network. When the response body includes
"code":"42501", the row level security policy is denying the request before any data leaves the database. - Replit Agent enables RLS automatically when it provisions Supabase, but does not always write every policy. Tables created later in the session can land with RLS on and zero policies, which denies all access by default.
anonandauthenticatedare the two Postgres roles Supabase maps client requests to. Policies must target the correct role withTO authenticatedor the request fails for logged-in users.- Expo apps need environment variables prefixed with
EXPO_PUBLIC_. Anything else stays undefined at runtime, so the client falls back to an empty key and Supabase responds with 401 or 403. - An UPDATE policy needs both
USINGandWITH CHECK. Omitting either side returns 403 for valid users editing their own rows.
Why does Replit Agent leave Supabase tables with broken RLS?
The short answer is that Replit Agent ships builds quickly and trusts you to verify the security layer before launch.
According to Replit's Expo tutorial and Supabase's API security guide, Supabase exposes the public schema through PostgREST by default, and any granted table without RLS enabled can be read by the anon role with matching API grants. When the Agent's prompt explicitly mentions Supabase, it usually provisions a project, sets up auth, and enables RLS on the tables it generates. That part is safe. The unsafe part is the second wave of tables the Agent adds during a long session. Each new migration can land with RLS on and no SELECT, INSERT, or UPDATE policy, which means every request from the mobile client returns 403.
The pattern is documented in the Supabase Row Level Security guide: when RLS is enabled and no policy exists for a given role and command, Postgres returns a permission denied error. From the mobile side, that surfaces as a 403 with body {"code":"42501","message":"permission denied for table ..."}. The Replit Agent does not flag this in the chat, so the first hint comes from the mobile log.
The correct response is to enumerate the tables the Agent touched in the session, check which ones have RLS on, and add the missing policies. Disabling RLS removes the safety net entirely, which is rarely what you want on production.
How do I check whether the 403 is really an RLS problem?
The short answer is to read the response body of the failed request, not just the status code.
A 403 with code: 42501 is an RLS denial. A 403 with message: "Invalid API key" or "JWT expired" is an auth header problem. A 403 with no Supabase body at all (just the platform's default error page) usually means the request was blocked before it reached Supabase, which points to a Replit preview proxy, a Capacitor or Expo origin issue, or an SSL certificate that the mobile WebView refuses to trust.
In an Expo or React Native app on a Replit Agent build, the easiest way to read the body is to wrap the Supabase call and log error.code, error.message, and error.details directly. The Supabase client returns those fields on every failed query. If error.code is 42501, stop debugging headers. If it is PGRST301 or similar, the issue is JWT or grants. The diagnosis takes thirty seconds and saves an hour of policy rewriting that was not needed.
| Symptom in the mobile log | Most likely cause | First fix to try |
|---|---|---|
403 with "code":"42501" | RLS enabled, no matching policy | Add SELECT or INSERT policy for authenticated |
403 with "message":"Invalid API key" | Anon key missing or misnamed | Check EXPO_PUBLIC_SUPABASE_ANON_KEY |
403 with "message":"JWT expired" | Stale session, no auto refresh | Set autoRefreshToken: true on client |
| 403 served as a Replit HTML page | Preview proxy or SSL trust issue | Test against production supabase.co URL directly |
| 403 on UPDATE only | Missing WITH CHECK clause | Add WITH CHECK (auth.uid() = user_id) |
What environment variable does Replit Agent expect for the Supabase anon key?
The short answer is EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY (or EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY in newer key naming) for Expo-based builds, set in Replit's Secrets pane.
According to the Supabase Expo React Native quickstart, Expo requires environment variables to be prefixed with EXPO_PUBLIC_ to be accessible in the app at runtime. Anything else stays undefined when the bundle is built. A common Replit Agent slip is naming the secret SUPABASE_ANON_KEY (no prefix) because that name reads cleanly. The web preview happens to work because Next or Vite resolves it through a different convention, then the mobile build silently falls back to undefined, and the Supabase client sends a request with no apikey header. Supabase responds with 401 or 403 depending on which check fails first.
The verification step is to log process.env.EXPO_PUBLIC_SUPABASE_URL at the top of the entry file. If the log line is empty, the variable name or the bundling setup is the issue, not RLS.
A second slip is sending both an apikey header and an Authorization header where the Authorization JWT is malformed. The Supabase troubleshooting docs note that Authorization takes priority, so a bad JWT on a request that has a perfectly valid apikey still returns 403. In a Replit Agent app, this can happen when the auth flow is half wired and the session token is null but the client still sets an Authorization header on some calls.
How do I rebuild the missing RLS policies correctly?
The short answer is to add one policy per table per command for the authenticated role, with the right combination of USING and WITH CHECK.
For a posts table with a user_id column:
alter table public.posts enable row level security;
create policy "Users can read their own posts"
on public.posts for select
to authenticated
using (auth.uid() = user_id);
create policy "Users can insert their own posts"
on public.posts for insert
to authenticated
with check (auth.uid() = user_id);
create policy "Users can update their own posts"
on public.posts for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
Two patterns trip up Replit Agent builds in particular. The first is forgetting WITH CHECK on UPDATE. The GitHub issue thread on Supabase RLS 403 errors covers this in detail: an UPDATE policy needs both clauses because Postgres validates the row before and after the change. The second is policies that target the default public role rather than authenticated, which works in some queries and fails in others. Target authenticated explicitly.
Once the policies exist, the test that actually proves they work is a runtime check, not a scanner pass. Send a request with no JWT and confirm the response is empty. Send a request with one user's JWT and try to read another user's row. If both return empty, the policy is doing its job. If either returns data, the policy lets too much through.
What about SSL certificate errors on a Replit-deployed Supabase call?
The short answer is that the Supabase URL itself is fine. The SSL trouble usually sits on Replit's custom domain layer or on the mobile WebView.
Replit's published status history lists several incidents where custom domain SSL certificates failed to issue or expired, which produces a network-level rejection in any mobile WebView before the call ever reaches Supabase. The mobile log shows a generic 403 or an NSURLErrorDomain failure with no Supabase body. The way to confirm this is to fire the same Supabase call directly against the project's https://*.supabase.co URL, not through a Replit-hosted proxy. If the direct call works, the issue is the deployment layer.
For Capacitor builds, an extra step is needed: Android WebView rejects certain TLS chains under stricter network security configs, so the network_security_config.xml should explicitly trust the Supabase host (or the WebView should use the default config when the API is on supabase.co).
Can I avoid this whole class of bug on the next Replit Agent build?
The short answer is yes, with three habits that take less than ten minutes per session.
First, ask the Agent at the end of every Supabase-touching session to list every table it created or modified, and to print the RLS status and policy count per table. The Agent answers honestly when asked directly. Anything with RLS on and zero policies is a 403 waiting to happen.
Second, store the Supabase anon key under EXPO_PUBLIC_SUPABASE_ANON_KEY (or the new publishable key name) in Replit Secrets, and log the variable once at app start during testing. If it ever logs as undefined, the bundling is broken.
Third, before any submission to App Store Connect or Google Play, run a separate review against the OWASP Mobile Application Security Verification Standard. For builders who want an automated external read of the compiled APK or IPA before submission, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission scanning of AI-coded mobile builds aligned with OWASP MASVS.
What to watch out for
The first trap is disabling RLS to make the 403 go away. That moves the problem from "no users can read" to "any anon user can read everything", which is a much worse failure on production. Leave RLS on and add the missing policy.
The second trap is treating a 403 from the platform's HTML error page as a Supabase 403. They share a status code and nothing else. The mobile log should show a JSON body from Supabase if the request actually reached the database.
The third trap is copying example policies that use USING (true). The policy exists, the scanner shows it as enabled, and every row is readable. The Supabase RLS guide is explicit that policies should encode the access rule, not just satisfy the presence check.
Key takeaways
- A 403 from Supabase on a Replit Agent mobile build has four common causes: missing RLS policies, a wrong environment variable name, a bad or missing JWT, or a Replit deployment SSL issue. Read the body before changing the database.
- UPDATE policies need both
USINGandWITH CHECKclauses with the correct role target (authenticatedfor logged-in users). - The mobile build needs
EXPO_PUBLIC_prefixed secrets to see the anon key at runtime; a missing prefix produces a silent 401 or 403. - Runtime testing (a logged-out request, a cross-user request) is the only check that proves an RLS policy actually denies what it should.
- Some teams pair an AI-coded build with an external pre-submission scanner. PTKD.com (https://ptkd.com) is one of the platforms focused on automated scanning of compiled mobile builds against OWASP MASVS before they reach App Store Connect or Google Play.



