Here is a two-line audience check from an MCP server that was written to be strict:
if (aud && aud !== me) {
return reject(401)
}It rejects any token issued for a different resource. It passes AUDIENCE-001 cleanly. It still has a critical bug.
A token whose JWT payload has no aud claim at all makes the first condition false. The reject never runs. The server accepts the token.
Where this comes from
A reader on the MCP subreddit raised it as a follow-up to the first audience post. The pattern reads defensively. It looks like a tightening of a looser check written earlier. The author almost got it right. The one case they did not cover is also the one that is easiest to produce: mint a token on the authorization server without a resource parameter.
That is not an exotic token. Many authorization servers issue tokens without an aud claim by default when the client does not request one, because the aud claim is optional in the base OAuth 2.0 token response. OAuth 2.1 and MCP make it mandatory on the resource server side, but a lot of legacy code checks like the snippet above.
What the spec actually says
RFC 9068 Section 4 covers JWT access tokens. The requirement for a resource server is one sentence: the server MUST reject any JWT whose aud claim does not contain its own resource identifier. The spec is explicit that "does not contain" includes "is not there at all". A missing aud is not a neutral state. It is a wrong value.
Why the first audience check misses it
AUDIENCE-001 sends a token minted for a sibling resource. The server with the two-line check seesaud = "https://other-service", enters the condition and rejects. Pass. The scanner moves on.
AUDIENCE-002 sends a token with no aud at all. Same server, same code path, different branch: aud = undefined, condition false, reject never runs. Accepted. Critical finding.
One check without the other would miss this class of server entirely. The two cover a small matrix together.
How to run it
npx -y @patchbay-nebius/agent audit https://mcp.example.com/mcp \
--probe-missing-aud=$NO_AUD_TOKENYou mint NO_AUD_TOKEN on your own authorization server by requesting a token without a resource parameter. The token must be signed by the same authorization server the MCP server trusts. patchbay sends one tools/list with that token in the Authorization header and records what the server does.
If the server returns 401 or 403, it passed. If it returns 200 with a tools list, it accepts tokens without an aud claim. AUDIENCE-002 fires critical.
The fix
Rewrite the check so a missing claim is a reject, not a pass-through:
// single-string aud
if (aud !== me) return reject(401)
// array aud
if (!Array.isArray(aud) || !aud.includes(me)) return reject(401)If your authorization server has a way to require the resource parameter on every token request, enable it. That removes the ability to issue aud-less tokens at the source and gives you a second line of defence in case a future resource server slips again.
Why probes like this stay opt-in
The check needs a real signed token. patchbay does not have your authorization server's signing key and should not. That is the whole point of an opt-in probe: the person running the audit mints a single short-lived token on their own authorization server, passes it in by flag. The probe reuses it for one request.
If you ship an MCP server and your tools/list is behind authentication, pass a bearer token in with --auth as well so the rest of the checks can see the same surface the authenticated client sees. Then the missing-aud probe is one more flag next to it.
The other follow-up probe this unlocked is the headless OAuth 2.1 conformance battery, which tests what the /authorize endpoint accepts in practice. If you run one, consider running the other. That post is here.