Anon Key Exposed in Frontend Bundle Supabase
Anon key exposed in frontend bundle is by design. Developers panic because they confuse it with service_role. Learn the difference and RLS best practices.
- The anon key is public by design, not a breach: Supabase architecture requires it in your frontend code
- Service_role key is the real secret: exposing it via NEXT_PUBLIC_ or VITE_ prefixes bypasses all access controls
- VibeEval found 1 in 9 indie apps leak credentials because AI codegen tools (Lovable, Cursor, Bolt) often pick the wrong key
- RLS is your actual defense: an anon key can only access what RLS policies permit, so misconfigured RLS is the only real risk
- Detect leaked service_role by searching your bundle for JWTs starting with 'eyJ' and checking the 'role' claim using jwt.io
Your Supabase anon key is visible in your frontend bundle. This is intentional design, not a bug. Developers panic because they confuse it with the service_role key, which absolutely must be secret. The actual risk is Row-Level Security misconfiguration, not mere visibility. This guide explains the difference and shows you what to fix.
Why Developers Panic About Anon Keys (And Why They Don't Need To)
The anon key is designed for public use in frontend code. Supabase chose this architecture to keep authentication simple and stateless. Developers panic because they assume anything visible must be secret. But visibility is not the threat model. The service_role key is the secret that must never appear in client bundles.
If you have shipped a Supabase app, you have exported the anon key in your.env.local or environment variables. You did this because your build system (Vite, Next.js, Webpack) needs it to make database queries from the browser. This is normal. This is expected. Supabase designed this exact workflow.
The panic comes from a misunderstanding. You see the key in your bundle. You see it in your GitHub repo. You see it in your browser's devtools Network tab. Your brain says: 'This is a secret. Secrets must not be visible. I have exposed a secret. My database is compromised.' That is wrong.
The anon key is not a secret. It is a public identifier, like a domain name. What makes it safe is Row-Level Security (RLS). RLS is the lock. The key is just the door handle.
How Supabase's Two-Key System Works
Supabase gives you two keys: anon (public, for frontend) and service_role (secret, for backend). Both are JWTs signed with your project's secret. The only difference is the 'role' claim in the JWT payload. Anon requests carry role='anon'. Service_role requests carry role='service_role'. RLS policies check this claim to enforce access.
The anon key is a JWT (JSON Web Token). When decoded, it contains:
{
'iss': 'https://<project-id>.supabase.co/auth/v1',
'sub': 'user-id-or-anonymous',
'role': 'anon',
'aud': 'authenticated',
'iat': 1234567890,
'exp': 1234571490
}
The important part is 'role': 'anon'. Every request you make from your frontend carries this claim. When your query hits the database, Supabase checks: what role is this request claiming? It sees 'anon' and applies the RLS policies written for the anon role.
The service_role key is also a JWT. It differs in one critical field:
{
'iss': 'https://<project-id>.supabase.co/auth/v1',
'sub': 'service_account',
'role': 'service_role',
'aud': 'authenticated',
'iat': 1234567890,
'exp': 9999999999
}
Notice 'role': 'service_role' and no expiry. If you use this key in your frontend, every request claims role='service_role'. RLS policies for service_role are typically 'allow all' because they are meant for admin operations on the server only.
Supabase's GitHub repository has 108.4k stars and is trusted by thousands of developers. The two-key system is central to that design. Seeing the anon key in your code is proof the system is working as intended. Seeing the service_role key in your code is proof something went catastrophically wrong.
The Real Risk: Service-Role Key Exposure (Not Anon)
Exposing the service_role key is catastrophic. It grants unrestricted database access, bypassing all RLS policies. Developers leak it by accident using NEXT_PUBLIC_ or VITE_ prefixes on the wrong variable, or by copying the wrong key from Supabase settings. AI codegen tools (Lovable, Cursor, Bolt) often generate this mistake automatically.
One rule to live by: never prefix the service_role key with NEXT_PUBLIC_ or VITE_.
In Next.js, environment variables prefixed with NEXT_PUBLIC_ get baked into the frontend bundle. In Vite, variables prefixed with VITE_ get exposed to the browser. If you do this to service_role, it ships with your JavaScript. An attacker downloads your bundle, finds the JWT, and makes requests with role='service_role'. They bypass every RLS policy. They can drop tables. They can read and modify every row in your database.
This is why VibeEval found that 1 in 9 indie apps shipping Supabase apps leak credentials this way. It is not because developers are careless. It is because AI codegen tools are not careful. Lovable, Cursor (with @codebase), and Bolt all generate code without checking whether a secret is landing in the client bundle.
The typical mistake unfolds like this: you create a Supabase project. You copy the anon key and service_role key from Supabase settings. You paste both into your.env.local. You name them VITE_SUPABASE_ANON_KEY and VITE_SUPABASE_SERVICE_ROLE_KEY. You use import.meta.env.VITE_SUPABASE_ANON_KEY in your app. Vite sees the VITE_ prefix and exposes both variables to the browser. Service_role is now in your build output. The bundle is compromised.
The fix is surgical: service_role lives on the server only. Not in.env files. Not in GitHub. Not in the build. Only the backend (Edge Functions, API routes, server-side rendering) can see it.
What Attackers Can Actually Do With an Anon Key
An attacker with the anon key can make only the requests your app can make. They cannot bypass RLS. They cannot access rows they should not. Their damage is limited to what anon role RLS policies permit. Without proper RLS, they can read any unprotected table, which is why RLS misconfiguration is the real risk.
The anon key does not grant admin powers. It grants the exact powers your app grants users before they log in. If you allow anon users to read public posts, an attacker can read public posts. If you allow anon users to comment, they can comment. If you allow anon users to do nothing, they can do nothing.
This is the crucial insight: the anon key is not a vulnerability unless your RLS policies are broken.
Example: you build a note-taking app. Users must log in to see notes. Your RLS policy says:
CREATE POLICY anon_no_access ON notes
FOR SELECT USING (auth.role() = 'authenticated');
An attacker finds your anon key in the bundle. They craft a request to read the notes table with role='anon'. The policy checks: is auth.role() equal to 'authenticated'? No. Access denied. The anon key is useless.
Now consider a second app with a different RLS policy:
CREATE POLICY anon_read_all ON notes
FOR SELECT USING (true);
This policy allows anyone (anon or authenticated) to read any note. An attacker with the anon key can read every note in the database. But the anon key did not cause this. This is exactly the scenario described in the post on users being able to see other users' data in Supabase; it happens when RLS policies are either missing or overly permissive. The misconfigured RLS policy caused this. The key is just the mechanism.
Row-Level Security: Your Real Defense
RLS is not a firewall. It is a per-row access rule engine. Every SQL query hits RLS policies before returning data. An RLS policy is a SQL WHERE clause applied by the database, not by your app. Policies run for every request, including requests with the anon key. Misconfigured RLS is the only real threat.
RLS works by attaching a WHERE clause to every query. When you execute SELECT * FROM users, Postgres does not actually run that. It runs SELECT * FROM users WHERE <RLS policy>. The policy is a SQL expression that returns true or false for each row.
Example: a user-profile table where each user should see only their own profile.
CREATE POLICY user_sees_own_profile ON user_profiles
FOR SELECT USING (auth.uid() = user_id);
When a user with uid='12345' queries the user_profiles table, the database actually runs: SELECT * FROM user_profiles WHERE '12345' = user_id. Only rows where user_id equals 12345 are returned. Rows for other users are invisible. The database enforces this, not your JavaScript.
This is why saying 'RLS is enabled' is not enough. You can enable RLS and still expose every row. You need RLS policies that actually restrict. Each policy must be specific to the role and the action.
The mistake most developers make is writing overly permissive policies. Example:
CREATE POLICY anon_can_read_everything ON users
FOR SELECT USING (true);
This policy says: 'For SELECT queries, return true for all rows.' It is open to everyone (anon and authenticated). If this is your only policy, an attacker with the anon key can read every user record.
The correct pattern is to write policies for each role and each action. Then test them. The guide on how to test multi-user access control in your app covers this in detail. You should verify that anon role cannot access sensitive tables. You should test multi-user scenarios to ensure one user cannot see another's data.
How to Detect Leaked Keys in Your Bundle
Search your build output for JWTs starting with 'eyJ'. Decode them with jwt.io or a CLI tool. Check the 'role' claim. If it says 'service_role', you have a leak. If it says 'anon', that is normal. Run Vibe App Scanner or VibeEval to automate this check and prevent leaks in CI/CD pipelines.
JWTs always start with eyJ. If you run grep -r 'eyJ' on your dist folder, you will find any JWTs in your build. They look like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3lvdXJwcm9qZWN0LnN1cGFiYXNlLmNvIiwicm9sZSI6ImFub24ifQ.signature
You can decode this online at jwt.io. Paste the token. Look at the Payload section. Find the 'role' field. If role equals 'anon', you are fine. If role equals 'service_role', you have a leak. Delete the environment variable and rotate the key immediately.
For automated detection, use VibeEval or Vibe App Scanner. Both scan your bundle for exposed API keys and credentials. They are free or low-cost. Run them before deploying.
In CI/CD, add a pre-deploy check:
grep -r 'service_role' dist/ && echo 'FAIL: service_role in bundle' && exit 1
This command searches your dist folder for the string 'service_role'. If found, it fails the build. No deploy happens. Catch the leak before it ships.
More sophisticated checks parse your bundle, extract JWTs, decode them, and check the role claim. Most modern JavaScript bundlers (Webpack, Vite, esbuild) support these checks as plugins. If you use Vite, community plugins exist for this exact purpose. The goal is making it impossible to accidentally ship service_role.
You should also search for the service_role key itself (the long string from Supabase settings). It does not start with eyJ if stored as plaintext. But it is equally catastrophic if found. Most CI/CD services offer secret scanning; enable it. GitHub, GitLab, and other platforms can detect known patterns of API keys and flag commits.
Emergency Response: If the Service-Role Key Leaked
If service_role is exposed, rotate it immediately. Do not wait to understand the breach. Supabase disables the old key instantly on rotation. Next, check database logs for suspicious queries. Move privileged operations to Edge Functions or backend API routes. Finally, add CI/CD checks to prevent future leaks.
Step 1: Rotate the key right now. Go to Supabase Settings > API keys. Click the three-dot menu next to the service_role key. Select 'Rotate'. Confirm. The old key is dead. All existing requests with the old key fail. Yes, this breaks production if you are using the old key anywhere. That is the point. Better to notice the break immediately than to find out three months later that an attacker looted your database.
Step 2: Check your database logs. Go to Supabase > Logs > Database Logs. Search for unusual queries after the leak date. Look for DELETE or DROP statements on critical tables, SELECT statements reading sensitive columns, INSERT statements into tables an attacker should not access. If you see none, you got lucky. If you see some, you have forensics work to do. Document it.
Step 3: Move privileged operations off the client. If your app has administrative operations that currently use a service_role connection, refactor them. Use Edge Functions (Supabase's serverless option) or a backend API route (Node.js, Python, whatever). The backend can safely use the service_role key because it is not shipped in a bundle. Backend code runs on your server; it is not inspectable by users.
Example: you have a 'delete user' function in your frontend. Instead, add an API route that runs only on the server. The backend uses the service_role key. The frontend calls the API route. The guide on how to set up custom SMTP for Supabase covers backend integration patterns; it is worth reviewing for inspiration on moving secrets off the client.
Step 4: Add CI/CD checks. Prevent this from happening again. Add a pre-deploy script that fails the build if service_role appears anywhere in the bundle. Git also has commit hooks. Most modern projects use pre-commit or similar tools. Add a pattern to catch environment variable names like VITE_SERVICE_ROLE or NEXT_PUBLIC_SERVICE_ROLE before they get committed.
Move Beyond DIY Secrets: Managed Hosting Alternatives
If managing secrets in your codebase feels risky, managed hosting is an option. Ship (Opsily) handles secrets for you: environment variables are server-only, never shipped in bundles. You pay a flat rate instead of per-query. Hetzner with Coolify is cheaper raw cost, but requires DIY secrets management. Ship trades raw price for peace of mind.
Building a production app means managing secrets. Every connection string, API key, and credential must be protected. The DIY approach (Hetzner VPS plus Coolify) gives you full control. You provision a server, deploy your code, and rotate keys yourself. The raw cost is lower: Hetzner cloud servers start at six dollars per month. Coolify is free and open-source.
But 'lower cost' comes with tax. You own the secrets management. You own the deploy pipeline. You own the incident response if a leak happens. Your team must remember to rotate keys quarterly. You must write or find CI/CD checks. You must monitor logs yourself.
Ship takes that burden away. You connect your Git repo. Ship builds and deploys your app. Secrets are environment variables that exist only on the server. They never touch your frontend bundle. They never land in your build output. Your CI/CD is locked in. By design, you cannot accidentally ship a secret.
Ship costs a flat monthly rate instead of usage-based pricing. This matters if your traffic varies. With Supabase or Hetzner, you pay per query or per gigabyte. If traffic spikes, cost spikes. With Ship, you pay the same flat rate next month. Budget predictability wins for small teams.
You should choose based on your risk tolerance and team size. A single developer? Hetzner plus personal discipline works. A team of five? Ship's pre-baked security checks save time and stress. A team of fifteen? Ship's logging and monitoring integrations pay for themselves.
Ship is not free. But it costs less than the engineering time you spend managing secrets, writing deployment scripts, and fielding security-audit questions. Trade raw price for predictability and peace of mind.
The decision tree: raw cost is your only concern = Hetzner plus Coolify. You want security checks built in = Ship. You are a single developer with one app = Hetzner. You manage a small team with multiple apps = Ship. Both work. Choose consciously.
Frequently Asked Questions
Where can I find the anon public key in my Supabase project?
Navigate to Supabase Dashboard > Settings > API Keys. The anon (or 'Publishable Key' in older projects) is listed at the top. Copy it from there.
Are publishable key and anon key the same in Supabase?
Yes. 'Publishable key' is the legacy name. 'Anon key' is the current name. Functionally, they are identical: both carry role='anon' in the JWT.
What is an anon key?
A public JWT that identifies requests as unauthenticated. It is used by your frontend to make database queries before the user logs in. RLS policies control what anon requests can access.
Is it safe to store an API key in a database?
Never store keys in the database or any code. The anon key is an exception: it is not a secret, so it can live in frontend code. The service_role key must never be stored anywhere a human or script can read it outside the backend server.
What happens if my API key is leaked?
Depends which key. If anon is leaked, check your RLS policies and verify they still restrict access. If service_role is leaked, rotate it immediately. Service_role bypasses all RLS; it is catastrophic.
Why is it bad to expose API keys?
Most API keys are credentials that grant access to everything. The anon key is not; it is a public identifier. Service_role is a credential and is catastrophic if exposed. The distinction matters.
The Bottom Line
Your Supabase anon key in the frontend bundle is intentional design. Do not panic about its visibility. Panic instead about whether your RLS policies are correct. Test them. Verify that anon role cannot access sensitive data. Verify that authenticated users can only see their own data. Use tools like Vibe App Scanner to detect the real leaks: service_role in the bundle or misconfigured RLS.
If you are tired of managing secrets and running CI/CD checks yourself, managed hosting like Ship handles the repetitive security work. It costs more than a bare Hetzner VPS but less than the engineering time you save. Start with an RLS audit. Run Vibe App Scanner. Then decide whether to DIY or delegate.