Safeguard
Application Security

Your App Issues Two Kinds of Token and One Verifier Only Knows One

An SSO user carries your auth service's token, not your identity provider's. A verifier that accepts only the provider's tokens rejects exactly the users it was built for, and the symptom is a button that does nothing.

Priya Raman
Staff Security Engineer
6 min read

A user signs in with Google, lands in the application, and one button does nothing. No error, no spinner, no message in the console that means anything. Other users are fine. The same user signing in with a password is fine.

What has happened is that your system issues two different kinds of bearer token, and the code behind that one button only knows how to verify one of them. This post is how that situation arises, why the symptom is a dead control rather than an error, and how to fix it without creating a worse problem.

How a system ends up with two token families

Almost always by growing in a reasonable order.

You start with your own authentication: a login endpoint that checks a password and signs a token with a secret you hold, usually HMAC. Simple, and every service verifies it with the same shared secret.

Later you add single sign-on, because an enterprise customer requires it. You stand up an identity provider, or integrate with theirs. Now there is a second issuer, signing with asymmetric keys published at a JWKS endpoint, with different claims and a different audience.

The natural implementation is for your login endpoint to accept the SSO assertion, then issue its own token, so everything downstream sees the token it already understands. That is a good design. It keeps session semantics in one place.

But the migration is rarely complete. Some services are written against the identity provider's token directly, because that was the obvious thing when SSO was the new work. So you have two families in circulation:

  • Tokens your auth service signs, held by everyone who logged in through your own login, including SSO users.
  • Tokens the identity provider signs, held by whatever integrated with it directly.

The trap is that last point. An SSO user does not carry an identity provider token. They carry yours. A verifier written to validate only the provider's tokens rejects exactly the users it was built for, which is the opposite of what everyone assumes when reading the code.

Why the failure is silent

A verifier that cannot validate a token returns 401. That is correct behaviour and it is where the silence comes from.

The frontend has a global handler for 401 that redirects to login or refreshes a token. On a request that was already authenticated, that handler either does nothing visible or quietly retries. The button's handler never gets a response it understands, so it never renders an error. Meanwhile the backend logs an authentication failure, which looks like ordinary internet noise in a log full of them.

Three properties combine to hide it: the status code is one your frontend treats as routine, the failure is per endpoint rather than per session, and it correlates with login method rather than with user or tenant. That last one is why it survives triage. Nobody reproduces it, because whoever tries logs in the way they always do.

Finding out whether you have this

Decode a token from each login path and compare the claims. You do not need tooling, only the middle segment of the JWT:

# paste the token, look at the payload
cut -d. -f2 <<< "$TOKEN" | base64 -d 2>/dev/null | jq .

Compare iss, aud, and alg across a password login and an SSO login. If iss differs, you have two families, and every verifier in the system is a place this can be wrong.

Then find the verifiers:

# anything that only knows one issuer
grep -rn "JWKS\|jwks_uri\|setIssuer\|withIssuer\|verifyToken" --include=*.java --include=*.ts .

Read each one and ask which family it accepts. The ones that name a single issuer are your candidates. On a codebase of any size this is a short list and the exercise takes an afternoon.

Fixing it

Verify by issuer, not by assumption. Read the iss claim first, then dispatch to the verifier for that issuer. Both families are legitimate, so the resolver should hold a map of issuer to verification method rather than a single configured one.

String issuer = JWT.decode(token).getIssuer();   // unverified read, for routing only
TokenVerifier verifier = verifiers.get(issuer);  // exact match, never startsWith
if (verifier == null) throw new AuthException("unknown issuer: " + issuer);
return verifier.verify(token);                   // signature checked here

Two cautions on that snippet. Decoding without verifying is safe only for choosing a verifier, because the signature is still checked before anything is trusted. And the lookup must be an exact match against an allowlist. Matching a prefix on an attacker-controlled claim is how you turn a routing table into an authentication bypass.

Normalise immediately. Convert both families into one internal principal at the edge, with a stable user identifier, tenant, and roles. Everything past the boundary should be unable to tell which family the request arrived with. If business logic branches on token type, the distinction has leaked and you will grow a third of these bugs.

Test both paths. The integration suite needs a password login case and an SSO login case, hitting the same endpoint. This is the check that would have caught the original bug and it is almost never present, because SSO is usually tested at login and nowhere after it.

The concession

The cleaner design is to have one token family. Exchange every external assertion for your own session token at the edge and let nothing downstream ever see a provider token. Then there is one verifier, one issuer, and none of this exists.

That is right and it is often not reachable in one step, because the services verifying provider tokens directly are usually the ones you least want to touch. The multi-issuer resolver is the intermediate position, and its honest cost is that it keeps both families alive, which means keeping both verification paths correct indefinitely. Treat it as a migration state with an end, not a destination.

The implication

Anything that makes authentication behaviour depend on how a user signed in will produce bugs that do not reproduce for the people investigating them. The 401 is correct at every individual layer, which is precisely why nothing upstream treats it as a defect.

So the useful audit is not of your login flow, which gets attention. It is of every place that verifies a token afterwards, asking of each one: which issuers does this accept, and which of my users hold something else.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.