The API key is an admission
A static key in a header is a password that never rotates, sent on every call. Where the key is enough, where it is guilty, and what to replace it with.
The API key is the most common authentication mechanism on the APIs we come across in audits, and the most misused. It is an application password: a static secret, sent in clear text in a header on every call, valid until explicit revocation, which in practice means forever.
It says one thing and one thing only: this call comes from a holder of the key. It does not identify the end user, carries no scope of rights, does not expire, and gets copied into a configuration file as easily as into a Slack channel. It is not that the key is bad in itself: it is almost always used beyond what it can do.
Where it is enough
There are uses where the key is the right tool, and saying so plainly is what makes refusing it elsewhere credible:
- identifying traffic between internal services of low sensitivity, where the point is to know who calls, to measure and to limit, not to protect the data.
- public read APIs over public data, where the key serves the quota and the billing.
- outbound webhooks, alongside a signature.
The common thread: the key is there to count and attribute, not to protect.
Where it is guilty
As soon as the API touches personal data, money or a business action, the key alone is below the state of the art, and the gap gets paid for at audit or at incident.
Three flaws, and they stack. It does not rotate: rotation demands coordination with the consumer, so it never happens, and keys outlive projects as they outlive suppliers. It carries no context: the backend receives "partner X's key", and it is left to invent the rest. It leaks well: Git repositories, logs, support tickets, Postman exports, and its infinite lifetime turns every leak into a permanently open door.
A key in a frontend is a public key
A key embedded in a single page application, a mobile application or any code running on the client is public. Not "exposed to a risk": public. You read it in the browser's network tab, in the JavaScript bundle, in the decompiled binary. No obfuscation changes that fact, it only adds a few minutes to the time needed to find it.
It identifies nothing. A key anyone can extract does not authenticate the application, it only indicates which application was supposed to call. That is routing and counting information, not proof.
The quota becomes a resource shared with strangers. The first use of an extracted key is to make use of it, and the quota gets consumed at the expense of the real users.
Revocation is an application update. On mobile, store review is counted in hours, but adoption across the installed base spreads over weeks. A compromised key stays live until the users have switched, and cutting the key before they have done so amounts to cutting the service.
What to do instead depends on who is supposed to be authenticated. If it is the user, it is an authorization code flow with PKCE, with no embedded secret, see access. If it is the application itself, you need a backend: the backend holds the secret and calls the API, and the frontend talks only to it.
If a public key is still needed, to count or to route, it gets treated as such: low quota, no sensitive data behind it, monitoring of its use. Restriction by origin only stops a browser and is bypassed with a single command, it does not count as protection. A public key acknowledged as such is acceptable, a public key treated as a secret is not.
What to replace it with
The standard answer is OAuth 2 in client credentials for machine-to-machine: short tokens, issued by an identity provider, with a scope. A leaked token is worth its lifetime, a few minutes to an hour, not years. That short lifetime still has to be set, and it is the setting that decides the value of the measure.
An objection belongs here, and it deserves an answer rather than being left to the
reader. A shared client_secret is itself a static secret, sent on every token request,
and one that never rotates for want of coordination with the consumer. Moving the problem
one notch does not solve it.
Two mechanisms close it. Client authentication by signed private key or by certificate, rather than by shared secret: the consumer proves it holds a key without ever transmitting it. And tokens bound to the holder, by mTLS (RFC 8705) or by DPoP, which make a stolen token unusable. Without one of the two, migrating to OAuth improves rotation and not authentication.
For the flows carrying the end user, the same framework carries their identity and their rights through to the backend, which removes the "invented context" problem.
For the highly sensitive partner, mTLS adds mutual authentication at the transport layer. It works very well, on one condition: that the certificate lifecycle belongs to someone, with an inventory and an expiry alert.
Migration without drama
You do not convert an installed base of consumers to OAuth by decree. The method that works looks like every successful deprecation:
- new subscriptions no longer have the choice: tokens from day one.
- the gateway accepts both mechanisms on the existing APIs, and logs who still uses the key.
- every consumer on a key gets a date, a guide, and the help needed.
- the key is closed API by API, on the stated date.
The hidden lever is the portal: if obtaining an OAuth client is self-service and takes five minutes, the migration moves. If it is a ticket, it will not move, and that will not be the consumers' fault.
The inventory of the mechanisms actually in service, API by API, is one of the first things we look at in an audit: the gap between the declared policy and what the gateway really accepts is usually the first finding of the report.
Published in May 2025.
On the same subject