StackAttestTechnical Trust
Exposed API keys

How to check a website for exposed API keys

Most writing on this subject is wrong in the same way: it treats every key a browser can see as a breach. Some keys are supposed to be there. One of them will empty your account. The useful skill is telling the two apart in under a minute, and then knowing what to do in the right order.

Run the free checkValidate a repositorySee an example Passport

What a run can establish about your credentials

Two different questions, answered by two different layers. Where credential material is referenced, including whether a privileged credential is reachable from code that ships to the browser, comes from reading the source. Whether the deployed system hands credential material to a visitor comes from asking it.

Repository validation

E2
Repository

Reads the source and reports the file and the line where credential material appears, including in code that ships to the browser, along with any environment file committed to the repository. The same pass covers git history, because a key deleted in a later commit is still readable in an earlier one and still works until it is rotated.

Runtime validation

E4
Runtime

Builds and runs the application, then asks the deployed system directly whether it serves credential material on the config, environment and debug paths that most often expose it. A source scan tells you what is in the code. This tells you what a visitor can actually fetch.

Public URL scan

E4
URL scan

Tests the deployed application from outside, the way any visitor reaches it: transport security, security response headers, and what the running service discloses about itself. It needs nothing but the address, and what it observes it observes against the running system. Its limit is reach rather than strength: a URL can never establish authorisation, data-layer or backup posture, and those controls stay uncovered.

Publishable identifiers, privileged credentials, and the gap between them

The categories matter more than the vocabulary, because every provider names them differently. Ask one question about any key you find: what can a stranger holding it do without your server agreeing?

A publishable identifier is meant to be readable

A payment provider's publishable key, a Firebase web configuration, a browser map key, a project's publishable database key: these name your project so a request can be attributed to it. They ship in client code by design, and their presence in a bundle is not a finding. They are safe for exactly one reason, which is that the service holding your data applies its own rules to whoever presents them. A stranger with your publishable key can do what you decided any visitor may do, and nothing else.

A privileged credential is the thing the rules are for

A payment provider's secret key, a model provider's API key, a database connection string, a cloud service account, a project's service key: each of these acts as you. The service on the other end does not apply your rules to it, because it is the credential your own backend uses to set those rules. A copy of one in a browser bundle gives every visitor whatever your backend can do. There is no configuration that makes that safe, and no amount of obfuscation that changes it.

The variable prefix decides exposure at build time

A name beginning NEXT_PUBLIC_, VITE_, PUBLIC_, REACT_APP_ or EXPO_PUBLIC_ is an instruction to the build tool to substitute the value into the client bundle. That is the documented behaviour and it works perfectly: no error, no warning, and nothing in the repository that looks different afterwards. A privileged credential given one of those prefixes stopped being secret when the build ran, not when someone found it.

Calling a model provider from the browser publishes its key

An AI application that talks to a model provider from client code has to put that provider's key in client code, which is why this pattern accounts for so many of these. It works in development, it works in the demo, and it bills you for every stranger who reads your bundle. The key belongs behind a route on your own server, which is also the only place you can apply a rate limit or a spending cap of your own.

Removing the key does not retrieve it

Deleting the line and deploying again changes what the next visitor downloads. It changes nothing about what previous visitors, crawlers, caches and archives already hold, and the old value keeps working until the provider stops accepting it. So rotation comes first and the code change comes second. Doing it the other way round feels like progress and buys nothing.

A key in the history is still a key

Deleting the file in a later commit leaves the earlier commit intact, and the earlier commit is what a clone, a fork or a mirror carries. On a public repository the value is readable by anyone who thinks to look, and searching for exactly this is automated. Rewriting history is a separate decision with its own costs, and it does not invalidate the credential either. Only rotation does that.

How to check, in the order that works

The first two steps tell you whether you have a problem. The rest are what to do about it, and their order is the part people get wrong.

  1. Read what the browser downloaded, not what you wrote

    Open the deployed site, take the JavaScript the browser actually fetched, and search it for the prefixes your providers use and for the values themselves. The build is what decides exposure, so a clean repository and an exposed bundle are perfectly compatible. If source maps are deployed alongside, they make this quicker for you and for everyone else.

  2. Sort what you found into the two categories

    For each key, ask what a stranger could do with it without your server agreeing. If the answer is anything you would not offer an anonymous visitor, it is privileged and it is a problem. If the answer is only what your rules already allow, it belongs where it is and the real question becomes whether those rules are right.

  3. Rotate anything privileged before you edit a line

    Issue a new credential at the provider and invalidate the old one, then change the code. Copies already downloaded cannot be recalled, so until the old value stops being accepted you have fixed nothing. Where a provider supports scoping or restricting a key, do that at the same time, but treat it as narrowing the blast radius rather than as a substitute for rotating.

  4. Move it behind your own server, then look at what it did

    Put the new value in a server-only variable and route every call that needs it through code that runs on a server, where you can also apply your own limits. Then read the provider's usage and audit logs for the period the old key was out. If you cannot rule out that it was used, plan on the basis that it was.

  5. Sweep the places a key hides after the obvious one

    Committed environment files, the git history, build logs and CI output, error reports, and configuration endpoints your framework serves in development and sometimes in production. A key that is out is usually out in more than one of these, and the sweep is what stops you doing this twice.

What the result looks like

An illustrative extract, not a real customer result. Every row carries the evidence grade behind it, and a control nobody could establish says so.

ControlStatusEvidenceNote
Secrets are managed outside source codeFailedE2A privileged provider key is inlined into the client bundle by its prefix
Transport security (TLS) is enforcedVerifiedE4Confirmed against the running deployment
Security response headers are presentPartialE4No content security policy on the document response
Error responses do not leak internalsVerifiedE4No internals returned when a request was made to fail
Verified backups and restore procedureUncoveredNot gradedNothing in the layers that ran can establish it

Mapped to published standards

Each control is tested against a named clause, so a result means something outside our own vocabulary. Mapping is not certification, and none of these bodies endorse StackAttest.

OWASP ASVS 5CWENIST SSDFOWASP API Security Top 10

What this does not tell you

  • It is not a penetration test. A deterministic check and a runtime probe are not an adversarial human engagement, and there are classes of weakness only a person hunting for them will find.
  • It is not SOC 2 and does not replace it. SOC 2 attests to organisational controls over time; this validates technical controls in the software. They answer different questions for different buyers.
  • It does not replace human due diligence. It gives a reviewer repeatable evidence to start from, which is a different thing from being the reviewer.
  • Controls the run could not reach are reported as uncovered rather than passed. A result you did not earn is worse than no result.
  • A secret scan recognises credential shapes. A homegrown token that looks like ordinary text can be missed, so a clean result is a reason to believe your source is clean rather than proof that nothing is there.
  • It does not test whether a key still works. Nothing in a run signs in to your providers or spends your credit. A run reports that credential material is reachable, not what anyone did with it. Whether it was used is a question for your provider's logs, and only they can answer it.

Questions

Is it a problem that my API key is visible in the browser?

It depends which kind of key it is, and that is the whole question. A publishable identifier is designed to be visible and its presence is not a vulnerability. A privileged credential in the same place is full access to whatever it unlocks, for anyone who opens the bundle. Sort the key first, then react.

How do I tell whether a key is safe to expose?

Ask what a stranger holding it could do without your server agreeing. If the answer is only the things you already allow any visitor to do, the key is publishable and the rules on the server are what protect you. If the answer includes anything else, it is privileged and it does not belong in client code.

What does a NEXT_PUBLIC_ or VITE_ prefix actually do?

It tells the build tool to substitute the value into the JavaScript sent to the browser. It is a deliberate feature for values that are meant to be public, and it applies to whatever you name that way, including a credential that was never meant to be public. Nothing warns you, because from the tool's point of view you asked for it.

Can I restrict the key instead of replacing it?

Restrictions are worth applying and they are not a substitute. Where a provider supports limiting a key by referring site, by address or by scope, those limits reduce what a stranger can do with it, and some of them are trivially spoofed by anything that is not a browser. For a credential that acts as your backend, the only measure that ends the exposure is issuing a new one.

A scanner flagged something in my repository. Is it real?

Sometimes not. Documentation samples, test fixtures and placeholder values all match the same patterns, which is why a flagged string is the start of the question rather than the answer. Work out which provider it belongs to and what it would let a stranger do, then treat it accordingly. A shape that matches nothing familiar can also be a credential, so a quiet scanner is not the end of it either.

What does StackAttest establish about this?

Repository validation reports where credential material is referenced, including from code that ships to the browser, and covers committed environment files and git history, with the file cited. Runtime validation asks the deployed system whether it serves credential material on the config, environment and debug paths that most often expose it. Each result carries the grade that records how it was established.

Related

Supabase service role keyAI app security auditVibe code auditBroken access control testFree AI app security check

Find out what is actually true of your application

Start with the layer you can run today. The report says which layers ran and what they could not reach, so the result is honest about its own limits.

Run the free checkValidate a repository