Skip to content
Danube Labs

Keys and Secrets in Development: Accidents, Not Attacks

Most leaked credentials were never hidden well: they were committed, pasted, logged or shipped to the browser. The practical rules that keep them out, and how to rotate one that got through.

By Alexandru Secureanu8 min read
A key file slipping out of a source repository into a chat, a log and a browser bundle

Almost every leaked credential we have seen was not attacked. It was committed to a repository, pasted into a chat to unblock a colleague, printed in a log line while debugging, or built into a JavaScript bundle because a variable name looked harmless. None of that takes skill to find, and a leaked key rarely announces itself until someone uses it.

What follows is the set of rules a developer can keep without a security team. They are boring on purpose. The point is that they survive a deadline.

A leak is not the same as an incident. A key committed to a private repository and deleted twenty minutes later has a short window; the same key left for a year on a public repository is a different problem. Size the response to the window, not to the panic.

Where credentials actually leak

Four channels cover most incidents:

  • Source control. A .env file, a hardcoded string in a config module, a test fixture with a real key, a notebook committed to the repository.
  • Conversation. A key pasted into a chat, an issue, a ticket, a pull request description, a screenshot of a dashboard.
  • Output. A log line, an error trace, a CI job log, a stack trace that prints a connection string.
  • The browser. A server key referenced from client code, or a variable named so that the bundler inlines it.

Each one is a different mistake with the same root: the key was treated as text, not as a permission.

Keep configuration in the environment, not in the code

The baseline is old and still correct. The twelve-factor app puts configuration in environment variables and gives a test worth memorising: whether the codebase could be made open source at any moment, without compromising any credentials. If the answer is no, the secret is in the wrong place.

A local .env file is how most of us approximate that on a laptop. It has to be ignored by Git before the first commit, because gitignore only applies to untracked files. Git's documentation is explicit: files already tracked are not affected, and to stop tracking one you run git rm --cached, then add the name to .gitignore.

What you commit is an example file: .env.example, with the names and no values, for example DATABASE_URL= and SUPABASE_SECRET_KEY=. It doubles as documentation: a new developer sees which variables exist without seeing a value.

The prefix is a disclosure switch

Frameworks decide what reaches the browser by name. In Next.js, variables without the NEXT_PUBLIC_ prefix are only available in the Node.js environment and are not accessible to the browser, while a NEXT_PUBLIC_ variable is inlined into the JavaScript bundle at build time and frozen there. A key renamed with the prefix because the client supposedly needs it ends up in a file anyone can download. Read the list and ask of each variable: does the browser need this, or does the server?

Which keys can live in a browser

Supabase ships two key families on purpose, which makes it a clear example.

  • Publishable keys start with sb_publishable_. Supabase lists them as safe to expose in a web page, a mobile or desktop app, a CLI or source code. When no user is signed in, the key maps to the anon role; when a user signs in through Supabase Auth, it maps to authenticated. Row Level Security decides what either role may read or write.
  • Secret keys start with sb_secret_. They map to service_role, which has the BYPASSRLS attribute, so a secret key bypasses every Row Level Security policy. They belong on a server you control.

Neither is a JWT. Supabase notes that a long key beginning with eyJ is a legacy key, which is useful to recognise when a tutorial tells you to copy one.

The browser block on secret keys is not a safety net. Supabase matches the User-Agent header and returns HTTP 401 if a secret key is used from a browser, but the documentation says plainly that an attacker can still use the key from other tools. The block stops an accident, not a person.

The general rule follows. A publishable key is safe because it is designed to be held by strangers, and because something else, RLS in this case, is the real boundary. Remove the policies and the same key becomes a public doorway. If you are checking your own policies, we wrote about testing Row Level Security before you ship.

Supabase is deprecating the legacy anon and service_role keys by the end of 2026. Creating the new keys does not revoke the old ones; both systems work until the legacy keys are deactivated, and deactivation is reversible. That overlap is convenient for a migration and dangerous for a leak, because a key everyone forgot about still works.

The new keys are not JWTs, so they go in the apikey header, not in Authorization: Bearer, and Supabase lists the bearer form among the common causes of 401 errors.

What version control keeps, and why deleting is not enough

A commit is a snapshot of the whole tree, and Git keeps the objects that snapshot references. Delete the file in a new commit and the old commit still exists, still points at the old blob, and is still reachable from the branch history, from any tag, from a pull request and from every clone anyone ever made. Removing the current copy changes what the next checkout looks like. It does not change what the repository contains.

GitHub's documentation recommends the order you would expect: if the data is a secret, revoke or rotate it first, because history rewriting may not be necessary and does not undo exposure. If you still need it out of history, you rewrite locally with git filter-repo, which needs version 2.47 or later for its sensitive-data option, then force-push every branch, tag and ref.

Rewriting changes the hash of the commit that introduced the data and of every commit after it, invalidates signatures, and disrupts open pull requests. Refs under refs/pull/ cannot be pushed because GitHub treats them as read-only.

GitHub cannot remove the data from other people's clones, and if the commit exists in a fork it stays accessible there. Cached views can be reached directly by SHA. GitHub Support purges cached views only where it decides the risk cannot be mitigated by rotating credentials, and only after the repository is clean and no fork holds the data. Push protection blocks pushes that contain detected secrets to public repositories for users on GitHub.com by default, and a bypass needs a reason and is recorded.

That is why rotation is the remediation and history rewriting is the cleanup. Rewriting makes the repository presentable. Rotation makes the leaked value worthless.

How to rotate a key that may have leaked

Two words get used interchangeably and should not be. Rotating issues a new credential and leaves the old one valid until you retire it. Revoking disables the old one immediately. A rotation that never reaches the revoke step is a new key sitting next to a leaked one.

Supabase's procedure for a leaked secret key has four steps: create a new secret key in the dashboard, replace the compromised key everywhere the application uses it, confirm that every component uses the new key, then delete the old one. Deletion cannot be undone, which is why the confirmation comes first. The same page recommends one secret key per backend component, so a leak in one service forces one rotation instead of a coordinated outage.

The hard part is step two, because a key rarely lives in one place. The inventory is: environment variables in each environment, the CI secret store, deploy scripts, cron jobs, local .env files on several machines, a staging service nobody remembers, a notebook, a ticket and a chat thread. Each copy stays live until it is replaced or the key is revoked.

Both orders are defensible. Rotate then revoke keeps the service running while you chase copies, and suits an exposure that is old or theoretical. Revoke first suits a key you believe is in use right now: accept the outage, then rebuild access. What is not defensible is deleting the old key while a copy still authenticates somewhere, because the failure will surface later.

The legacy Supabase keys add a wrinkle. Their troubleshooting page says that regenerating the JWT secret invalidates all current secrets and severs every connection, and that for projects still on the legacy keys it is no longer possible to rotate the anon, service and JWT secrets; the path is to migrate. A project that never migrated has fewer options at the worst moment.

Logs, screenshots and the small accidents

The OWASP logging cheat sheet lists what should usually not be recorded directly: authentication passwords, access tokens, session identifiers, encryption keys and other primary secrets, database connection strings. Where the data is needed it should be masked, hashed or encrypted, and access to logs must be restricted and reviewed. A log is a copy of your system with weaker permissions than the system.

In practice, log a fact about a secret, never the secret. Supabase's Edge Functions guide shows the pattern: read the variable, reply with { configured: Boolean(secretKey) }, never the value. The same idea applies to a startup check, a health endpoint and an error handler.

Screenshots are the least reviewed artifact in a team. A capture of the dashboard key page, a terminal running env, or a curl command with an Authorization header carries the credential into a ticket, a slide or a chat. Crop it or retake it.

Builds are another quiet channel. Docker's documentation says build arguments and environment variables are inappropriate for secrets because they persist in the final image, and recommends secret mounts, available only for the duration of the build instruction. If a key was baked into an image, the image is a copy of the key.

A checklist that fits in a small team

  • Configuration comes from the environment. The repository holds an example file with names and no values.
  • Ignore .env* before the first commit, and check with git status instead of assuming. A file added to .gitignore after it was committed is still tracked.
  • Ask of every key what it can do, not whether it looks secret. If it bypasses your access rules, it never leaves the server.
  • Name public variables deliberately, and check the bundle rather than trusting the name.
  • Log booleans, never values. Blur screenshots. Do not paste keys into chat; share a secret manager entry instead.
  • Keep one key per component so a leak has a small blast radius, and write down where each one lives before you need the list.

None of this needs a security team, a tool or a budget. It needs the habit of treating a key as a permission that can be revoked, and the memory that a repository keeps everything you ever put in it. Two of these rules, the environment file and the example file, prevent most of the accidents we see. The rest decide how bad the day is when one gets through.

Frequently asked questions

Should I commit a .env file to Git?
No. Keep the real values in the environment or in a local .env file that Git ignores, and commit a .env.example with the variable names and blank values. Ignore the file before the first commit: gitignore does not affect files Git already tracks, so an already committed .env has to be removed from the index with git rm --cached.
Are Supabase publishable keys safe to expose in a browser?
Yes, when Row Level Security is enabled and the policies are correct. A publishable key starts with sb_publishable_ and maps to the anon role, or to authenticated when a user is signed in, so it only reaches what your policies allow. A secret key starts with sb_secret_ and bypasses every policy, so it must never leave the server.
Does deleting a secret in a new commit remove it from Git history?
No. The old commit still references the old file contents, and it remains reachable from the branch, tags, pull requests and every clone. Removing it for real means rewriting history with git filter-repo and force-pushing, which changes commit hashes and does not reach other people's clones or forks. Rotate the key first; treat the rewrite as cleanup.
How do I rotate a leaked API key?
Create a new key, replace the old one everywhere the application uses it, confirm that every component uses the new key, then retire the old one. In Supabase, deleting a secret key cannot be undone, so the confirmation comes first. Use a separate key per backend component so a leak forces one rotation instead of an outage across services.
Why is rotating a key in one place not enough?
Because a key is copied into CI secrets, deploy scripts, cron jobs, local .env files, tickets and chat threads. Until the old key is revoked, every copy still authenticates. Rotating issues a new credential; revoking disables the old one. A rotation that never reaches the revoke step leaves the leaked key working next to the new one.
Is it safe to print an API key in a log for debugging?
No. OWASP's logging guidance says access tokens, session identifiers, encryption keys and connection strings should not be recorded directly; they should be removed, masked or hashed. Log a fact instead, such as whether the variable is set. Logs are copied, shipped to third-party tools and kept longer than the code they describe.
Share

Related articles

  • 7 min read

    Client Accounts Do Not Belong in Your Main Browser

    A shared browser profile puts every client session in one cookie jar, under extensions that can read them all. Here is what a stolen session grants, and what real isolation means.

    • Security
    • Privacy
    • Developer tools
  • 7 min read

    Manage Multiple Vercel Accounts Without Logging Out

    Give every Vercel, Netlify, Cloudflare, Supabase or Gmail account its own isolated browser session and you can switch without logging out. Here are the options, the security trade-offs and the rules to check.

    • Developer tools
    • Productivity
    • Security
  • 9 min read

    Hosted or self-hosted for a client project: how to decide

    For a client project, hosted or self-hosted is a decision about duties, not taste. Six questions about data location, restore, patching, handover and exit, with the honest cases on each side.

    • Business
    • Developer tools