no-service-role-key-in-client
Keep the Supabase service_role key out of client code
Keep the Supabase
service_rolekey out of client code.
- CWE: CWE-798 — Use of Hard-coded Credentials
- OWASP: A02:2021 — Cryptographic Failures
- CVSS: 9.8 (Critical)
- Recommended:
error
Why
Supabase issues two keys. The anon key is meant to be public — Row Level Security is what constrains it, and shipping it to the browser is the design. The service_role key bypasses RLS entirely: it is a database superuser in a string.
A build that puts service_role in the browser bundle hands every visitor unrestricted read and write on every table, regardless of what policies say. It is not an escalation path; it is the end state.
The reason this needs a linter is that the line looks like every other one:
const supabase = createClient(url, key);Nothing at the call site distinguishes the two keys. The difference is one identifier defined elsewhere, and a reviewer scanning a diff for createClient sees a call they have approved a hundred times.
Rule details
The rule fires only in files that import a @supabase/* package, and reports four independent shapes:
- A service-role variable behind a public prefix.
NEXT_PUBLIC_,VITE_,PUBLIC_,REACT_APP_,GATSBY_,NUXT_PUBLIC_,EXPO_PUBLIC_. These prefixes exist to inline a value into the client bundle at build time, so the combination is wrong regardless of where the file sits. SvelteKit's public environment counts the same way: a service-role export imported from$env/static/public, or read off theenvof$env/dynamic/public. - A service-role variable read in a client component. A module with a
'use client'directive ships to the browser, so anything it reads goes with it. - A service-role variable given to a browser client.
createBrowserClient(@supabase/ssr) and the auth-helpers browser factories (createBrowserSupabaseClient,createClientComponentClient,createPagesBrowserClient) build a client that only ever runs in the browser, so the key ships whether or not the file says'use client'. The factory is matched through its import, and the key is followed through a same-fileconstand thesupabaseKeyoption. - A service-role key written into source. A JWT whose payload says
"role":"service_role", or ansb_secret_key, is reported wherever it sits —server-onlymodule included, because it is committed either way. The local-development keyssupabase startprints (issuersupabase-demo) and stand-ins that are not key-shaped (sb_secret_xyz) are not.
It matches on the property read off process.env / import.meta.env — never on a variable name. Rename every identifier in the file to foo and the rule still fires.
It abstains when the module imports server-only: the bundler fails the build if the client reaches such a file, so reading service_role there is correct.
Incorrect
import { createClient } from '@supabase/supabase-js';
// The prefix ships this to the browser.
export const db = createClient(
url,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY,
);'use client';
import { createClient } from '@supabase/supabase-js';
// A client component reads it, so the browser gets it.
export const admin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY);Correct
'use client';
import { createClient } from '@supabase/supabase-js';
// The anon key is what RLS is designed to constrain.
export const db = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
);import 'server-only';
import { createClient } from '@supabase/supabase-js';
// Unreachable from the client — the build fails if anything imports this.
export const admin = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY,
);Further reading
Use this rule
npm install -D oxlint-plugin-supabase-security{ "jsPlugins": ["oxlint-plugin-supabase-security"], "rules": { "supabase-security/no-service-role-key-in-client": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-supabase-security'], rules: { 'supabase-security/no-service-role-key-in-client': 'error', }, },});oxlint-plugin-supabase-security re-exports eslint-plugin-supabase-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.