A Valid JWT Signature Isn't Enough: Check the aud and iss Claims
Your auth middleware verifies the JWT signature, the signature checks out, and the request goes through. It feels finished. The token is cryptographically valid, minted by a key you trust, unmodified in transit. What more is there to check?
Quite a lot, and the gap is exactly where confused-deputy bugs live. A valid signature proves who issued the token. It says nothing about which service the token was for. In a microservices estate where every service trusts the same identity provider, a token your billing API happily issued to a low-privilege reporting client will sail straight through your payments service too, because payments checks the signature, sees a trusted issuer, and waves it in. Nobody wrote code to reject it. The library did not do it by default.
This is one of the quietest serious auth bugs there is, because every service passes its own tests. Each is tested with its own correctly-scoped token, so the cross-service replay never appears until someone, an attacker or a curious engineer, points Service A's token at Service B.
Verifying a JWT's signature is necessary but not sufficient. On every request also check:
aud(audience) equals this service's own identifier. A token minted for another service must be rejected here.iss(issuer) equals the exact issuer you expect. Do not accept tokens from any issuer whose key happens to validate.expand, where present,nbf. Most libraries check these, but confirm it rather than assume.
Do not rely on library defaults. Give each service a distinct aud value so a token cannot be replayed across services sharing one issuer.
The confused deputy, concretely
Say you run three services behind one identity provider that signs every token with the same key (or a key from the same JWKS). A client authenticates to get a token for the reporting API. That token has a valid signature. Now watch what a plain signature check does:
Token issued FOR: reporting-api (aud claim, if it is even set)
Token presented TO: payments-api
payments-api verifies signature -> valid (same trusted key)
payments-api checks aud -> NOT CHECKED
Result: reporting token accepted by payments. Confused deputy.
The payments service acted on the authority of a token that was never meant for it. That is the confused-deputy pattern: a trusted component (your payments service) is tricked into misusing its authority because it validated the wrong property. The signature was real. The scope was wrong. And no error fired, because to the library nothing was wrong.
Why libraries let this happen
Signature verification is the headline feature of every JWT library, so it is on by default and hard to skip. Claim validation is policy, and policy depends on your deployment, so most libraries make aud and iss checks opt-in: you pass the expected values and only then are they enforced. If you never pass them, the library verifies the signature, decodes the claims, and hands them back without judging them. That is arguably the right default for a general-purpose library and a footgun for the person wiring up auth in a hurry.
Some middleware historically shipped without any built-in aud/iss enforcement at all, so even developers who wanted it had to add it around the middleware. The result across the industry is a lot of services that check the signature and stop. The token being "valid" is doing a lot of unearned work in people's mental models.
The fix: enforce aud and iss explicitly
Step 1: Give every service its own audience value
Before touching code, fix the identity model. Each service must have a distinct aud identifier, and the identity provider must mint tokens with the audience of the service the client is actually calling. If every token carries aud: "internal" for the whole estate, there is nothing to distinguish, and even a correct audience check cannot help you. Use something stable and specific per service, for example https://payments.internal or payments-api.
Step 2: Pass the expected aud and iss to the verifier
Do not verify then inspect claims yourself in a second step where you might forget. Configure the verifier so a wrong aud or iss is a verification failure. In Node with the jsonwebtoken library:
const jwt = require('jsonwebtoken');
function verifyForPayments(token, publicKey) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // pin the algorithm too
audience: 'https://payments.internal', // THIS service's identifier
issuer: 'https://idp.example.com', // the exact expected issuer
});
// throws JsonWebTokenError('jwt audience invalid') on a wrong aud
// throws JsonWebTokenError('jwt issuer invalid') on a wrong iss
}
With Python's PyJWT, the equivalent is passing audience and issuer to decode, and the library raises InvalidAudienceError or InvalidIssuerError:
import jwt
def verify_for_payments(token: str, public_key: str) -> dict:
return jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience="https://payments.internal",
issuer="https://idp.example.com",
) # raises InvalidAudienceError / InvalidIssuerError on mismatch
The important property of both is that the check is part of verification, not a separate step you bolt on afterward. If you decode without passing audience, PyJWT does not validate aud at all, and jsonwebtoken the same. Passing it makes a wrong audience throw, which is what you want: a hard failure, not a value you have to remember to compare.
Step 3: Treat a missing aud as a rejection, not a pass
Decide explicitly what happens when a token has no aud claim at all. A token with no audience should not be universally accepted. Require the claim to be present and to match. In jsonwebtoken, if you set an expected audience, a token lacking aud fails, which is the behaviour you want. Do not add a code path that says "if there is no aud, allow it", because that recreates the hole.
Verification: prove the cross-service replay is now blocked
Write the test that would have caught this: take a token minted for another service and present it to this one. It must be rejected.
// mint a token as the IdP would for the reporting service
const reportingToken = jwt.sign(
{ sub: 'user-1' },
privateKey,
{ algorithm: 'RS256', audience: 'https://reporting.internal',
issuer: 'https://idp.example.com', expiresIn: '5m' },
);
// present it to the payments verifier
try {
verifyForPayments(reportingToken, publicKey);
throw new Error('SECURITY BUG: cross-service token accepted');
} catch (e) {
console.log(e.message); // "jwt audience invalid. expected: https://payments.internal"
}
The expected output is the audience-invalid error. If instead you see the security-bug line, your verifier is not enforcing aud. Add one such test per service pair you care about; it is cheap and it is exactly the case that slips through per-service tests that only ever use correctly-scoped tokens.
Watch out for the upgrade that starts rejecting valid-looking tokens
There is a flip side worth naming, because it looks like a regression and is actually the fix arriving. When a library or middleware adds default aud/iss enforcement in a new version, code that "worked" before can suddenly reject tokens it used to accept. Those tokens were the over-scoped ones you should have been rejecting all along. Do not disable the new check to make the errors go away. Fix the token minting so each service gets a correctly-scoped aud, then the check passes for legitimate calls and blocks the replays.
What people get wrong
"The signature is valid, so the token is trusted." This is the entire bug in one sentence. A valid signature authenticates the issuer, not the intended recipient. Trust requires the token to be for you.
"We use one shared audience for all internal services to keep it simple." That simplicity is the vulnerability. A shared audience means any internal token works against any internal service. Distinct audiences per service are the whole point.
"We check aud but not iss." Half the fix. Without an issuer check, a token from any issuer whose key happens to be in your trust set, including a different tenant on a shared IdP, can match. Check both.
"Scopes/roles in the token protect us." Scopes limit what an authorised caller may do; they do not stop a token intended for another service from being presented to yours. Audience is about which service, scope is about which action. You need both, and audience comes first. This complements the algorithm attack I cover in JWT algorithm confusion, which is a different way a signature check gets fooled.
When it is still broken
- Confirm the IdP actually sets aud correctly. If tokens come out with the wrong or a generic audience, no verifier config saves you. Decode a real token (it is just base64, not encrypted) and read the
audandissclaims to see what the IdP is really minting. - Gateways that strip or rewrite claims. An API gateway doing token exchange may replace the audience. Verify at the service, not only at the edge, so an internal caller cannot bypass the gateway.
- Multiple valid audiences. Some tokens legitimately carry an array of audiences. Ensure your check treats the
audarray correctly (your service's id must be a member), and do not accept the token just because some audience is present. - Clock skew hiding behind aud errors. If tokens are rejected intermittently, confirm it is truly an audience issue and not
exp/nbfskew between hosts. Read the actual exception type, do not assume.
A signature tells you the letter is genuine. The audience claim tells you it was addressed to you. Deliver mail to the wrong door long enough and someone will notice they can read yours.
Frequently asked questions
- Why is verifying the JWT signature not enough?
- A valid signature only proves the token was issued by a key you trust. It says nothing about which service the token was intended for. In a microservices setup sharing one issuer, a token minted for Service A will pass Service B's signature check too, so B must also verify the aud (audience) claim matches its own identifier to reject tokens meant for other services.
- What is the aud claim and how do I validate it?
- The aud (audience) claim names the service a token is intended for. Validate it by passing your service's own identifier to the verifier, for example audience in jsonwebtoken or the audience argument in PyJWT's decode. A token whose aud does not match, or that has no aud at all, should be rejected as a verification failure, not accepted.
- Should each microservice have a different audience value?
- Yes. Give every service a distinct aud identifier and have the identity provider mint tokens with the audience of the service being called. A shared audience for the whole estate means any internal token works against any internal service, which is exactly the cross-service replay you are trying to prevent.
- My JWT library started rejecting tokens after an upgrade. Should I disable the new aud check?
- No. If an upgrade added default aud or iss enforcement, the tokens now being rejected were over-scoped tokens you should have been rejecting all along. Fix the token minting so each service receives a correctly-scoped audience, then legitimate calls pass and the cross-service replays are blocked.