← All posts
·6 min read·GuardLayer

Hardcoding your Supabase service_role JWT is a permanent breach, even after you delete it

SupabaseSecretsJWTGit

There's a specific flavor of Supabase mistake that's worse than leaking a secret through an environment variable: pasting the service_role JWT — the literal eyJ... string — directly into a source file. Not a reference, not an env var name. The actual token, sitting in a string literal, committed to git.

The reason this is its own category of bad is the word permanent. An env var can be rotated and the exposure window closes. A hardcoded token enters your git history the moment you commit it, and it stays there in every clone, every fork, and every CI cache — even after you "fix" it by deleting the line in the next commit. The fix doesn't undo the leak. You have to assume the key is already burned.

What the service_role key actually is

Supabase issues two JWTs per project. The anon key is meant to be public; it's gated by your Row Level Security (RLS) policies. The service_role key is the master key: it bypasses RLS entirely. Decode its payload and you'll see "role": "service_role" — that single claim tells PostgREST and Postgres to skip every policy you've ever written.

So a hardcoded service_role JWT isn't "a credential in source." It's your entire database, read/write, handed to anyone who can read the file.

And these tokens are self-advertising. A JWT is three base64url segments — header.payload.signature — and the role lives in the middle one. You don't even need to decode it to spot a service_role token: service_role base64url-encodes to the fixed substring c2VydmljZV9yb2xl, so that exact byte sequence appears verbatim inside every service_role JWT. Automated scrapers grep public repos for that string. No decoding required — the role is right there in the token, just one base64 hop away from plaintext.

What it looks like in the wild

It almost always starts as a shortcut. Someone is wiring up a server-side admin client for a cron job or a webhook, the env plumbing isn't set up yet, and pasting the token "just to get it working" is faster than touching .env. Then it ships:

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

// Server-side admin client used by background jobs.
export const supabaseAdmin = createClient(
  "https://abcdefghijklmnop.supabase.co",
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFiY2RlZmdoaWprbG1ub3AiLCJyb2xlIjoic2VydmljZV9yb2xlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMTUzNjAwMDB9.xQ1pX0r7Tn2kLqZpY8mWvR3dF4hJgK6sB9cN0aE5uI"
);

Notice there's no NEXT_PUBLIC_ prefix and no "use client" here. This file is server-only, so the usual "you shipped it to the browser" advice doesn't apply — and that's exactly why people miss it. The file looks safe because it runs on the server. The problem isn't where it runs; it's that the secret is in the repo. Here's what GuardLayer reports on that exact file — live engine output, not a mockup:

guardlayer scan · lib/supabaseClient.tsLive engine output
Merge blocked
75/100 · B
  • Criticallib/supabaseClient.ts:6

    Hardcoded service_role JWT

    Remove the token from source, load it from a server-side environment variable, and rotate the key in the Supabase dashboard.

Why this is worse than env exposure

Three things make the hardcoded literal a distinct, nastier problem than a leaked env var:

  • Git history is forever. The token is captured in the commit object. Deleting the line in a later commit leaves the old blob fully intact and reachable via git log -p, any branch, any tag, any fork someone made before you noticed. Going from "leaked" to "not leaked" requires rewriting history, not editing a file.
  • It propagates silently. Every git clone, every CI runner that checked out the commit, every laptop with a copy now holds the live key. You can't recall those copies.
  • It's trivially harvestable. A leaked env var at least requires misconfiguration to expose. A committed service_role token in a public (or accidentally-public) repo is found by bots in minutes, because the encoded service_role claim (c2VydmljZV9yb2xl) sits inside the JWT as a fixed, greppable string.

The blast radius is total: read every row in every table, write or delete anything, forge records, dump auth data. RLS — your entire access-control model — is simply not in the picture.

The fix

1. Get the literal out of source and load it from the server environment.

import "server-only";
import { createClient } from "@supabase/supabase-js";

// server-only throws at build time if this module is ever imported into client code.
export const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // never NEXT_PUBLIC_, never a literal
);

Keep the value in .env.local (gitignored) and your host's secret store. The server-only package is a cheap guardrail that turns an accidental client import into a build error.

2. Rotate the key — this is non-negotiable. If the token was ever committed, treat it as compromised. In the Supabase dashboard (Project Settings → API), regenerate the service_role key. The old one dies the moment you do; update your env everywhere it's used.

3. Purge it from history. Deleting the file is not enough. Use git filter-repo or BFG to scrub the blob from every commit, then force-push and have collaborators re-clone. Do this after rotating, because rotation is what actually neutralizes the leak — history rewriting is cleanup.

30-second self-check

# 1. Any literal JWT in tracked source right now?
git grep -nE 'eyJ[A-Za-z0-9_-]{10,}\.eyJ' -- '*.ts' '*.tsx' '*.js' '*.jsx'

# 2. Was a service_role token EVER committed? (scans full history)
# A service_role JWT never contains the plaintext "service_role" — the role is
# base64url-encoded, so search for that encoded marker instead.
git log -p -S 'c2VydmljZV9yb2xl' --all

If the second command returns anything, you have a live exposure regardless of what your working tree looks like today — rotate before you do anything else.

FAQ

It's only in a server-side file, so isn't it safe? No. The risk isn't the runtime — it's that the secret is in your repository and git history. Anyone with read access to the repo (or a leaked clone) has your master key.

I deleted the line and committed the fix. Am I clear? No. The token is still in history. Rotate the key first, then rewrite history. Until you rotate, assume it's in use by someone else.

My repo is private. Do I still need to rotate? Yes. Private repos get forked, cloned to laptops, cached by CI, and occasionally flipped public by accident. A service_role key with no near-term expiry is too valuable to leave sitting in any history.

Why won't git log -S "service_role" find it? Because the token doesn't contain that string. A JWT's payload is base64url-encoded, so service_role appears inside the token as c2VydmljZV9yb2xl. Search history for the encoded form (or for the eyJ JWT prefix), not the human-readable one.

How is this different from putting it in a NEXT_PUBLIC_ variable? The NEXT_PUBLIC_ case ships the key to browsers via the bundle. This case ships it to anyone who can read your source or its history. Same catastrophic blast radius, different — and more permanent — exposure path.

Or skip the manual grep: GuardLayer flags hardcoded service_role JWTs (and 19 other Supabase + Next.js issues) on every push, with the fix inline.

Catch this before it ships — free

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

Keep reading