Your JWKS is supposed to publish public keys. Is it publishing the private one?
Single sign-on rests on a piece of asymmetric cryptography that's elegant when it works. Your identity provider signs a token with a private key. Everyone else verifies that token with the matching public key. To make verification easy, the provider publishes its public keys at a well-known URL, the JWKS endpoint, usually found by reading /.well-known/openid-configuration and following the jwks_uri. Browsers, APIs, and partner services fetch it, cache it, and trust any token it validates.
The entire scheme depends on one invariant: that endpoint serves only public material. The public key verifies a signature; it cannot create one. So publishing it to the world is safe by design. But a JSON Web Key has fields for both halves of the pair, and if the private fields ever end up in the JSON your JWKS serves, the math runs in reverse. Anyone who fetches that endpoint can now sign tokens, which means anyone can mint a valid, fully-trusted login as any user in your system. There is no brute force here and no stolen password. It's a forgery press, published.
What a private key looks like hiding in a JWKS
A public RSA key in a JWK has two parameters: n (the modulus) and e (the exponent). That's all a verifier needs. A private RSA key adds d, the private exponent, plus the CRT factors p, q, dp, dq, qi. For an elliptic-curve key, the public key is x and y; the private key adds d. The presence of a real d value is the whole tell. A conforming JWKS has no business containing it.
How does the private half get in there? Almost always a serialization mistake. A developer generates a key pair, stores it as a single JWK object that holds every field, and then wires the JWKS endpoint to return that object directly instead of stripping it to the public members first. The library will happily serialize the whole thing. Some misconfigured gateways and a few homegrown OIDC implementations do exactly this. It passes every functional test, because tokens still sign and verify, the endpoint still returns valid JSON, the login flow still works. Nothing is broken from the inside. The only symptom is that the JSON is too complete, and you only see that from the outside, by reading it the way an attacker would.
What forging one token actually buys
The reason this sits at the top of the severity scale is that token forgery isn't access to one thing. It's access to everything that trusts the token.
An OIDC token typically carries the user's identity (sub), their roles or groups, and an audience. With the signing key, an attacker writes their own claims: any user id, admin roles, any audience your services accept. The signature is genuine because it was made with your genuine key, so every service that verifies against your JWKS waves it through. It bypasses your login entirely. Multi-factor auth doesn't help, because MFA happens during login and the attacker never logs in. They walk in holding a ticket your own system printed.
How to check, and how to fix
The fix is to make the JWKS serve only public key material, and the check is to read your own endpoint the way the internet does.
// JWKS handler returning the stored key object verbatim
app.get("/.well-known/jwks.json", (req, res) => {
res.json({ keys: [storedKey] }); // storedKey has d, p, q...
});Use your crypto library's public-key export (exportJWK on a public KeyObject, to_dict(private=False), the equivalent in your stack) so the private fields are never in the object you serialize. Then verify by hand: fetch your own jwks_uri in a browser and confirm the JSON contains only n/e for RSA or x/y for EC, with no d. And if you find a private member there, treat it as already compromised, rotate the signing key now and invalidate the old kid, because anyone who fetched the endpoint while it was wrong can forge tokens indefinitely until that key is dead.
This is checkable from outside because the whole flow is public: the discovery document and the JWKS are URLs anyone can read, which is the point of them. SurfaceCheckr follows that exact path, reads /.well-known/openid-configuration, follows the jwks_uri only when it stays on your own registrable domain, and inspects the keys for a real private member. It flags a finding only when a d (or an RSA CRT factor) carries an actual base64url value, never on a normal public-only JWKS, and it reads the key fields without ever using them to sign anything. For the related case where a forged or long-lived token is sitting in your HTML directly, auth material a logged-out visitor can read in the page source is the companion finding, and the rule that sorts a safe public key from a dangerous secret one is the same instinct applied to API keys.
Read next
- Why your "public" key is fine but your secret key is a fireThe secrets hiding in your JavaScript
- Is your Stripe secret key in your JavaScript bundle right now?The secrets hiding in your JavaScript
- Why is there an AWS key in your build, and who can use it?The secrets hiding in your JavaScript
- An OpenAI or Anthropic key in your frontend is someone else's free computeThe secrets hiding in your JavaScript
Find it before someone else does.
Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.