Skip to content

Judge a JWT by its issuer, not its shape - #41

Merged
haruotsu merged 3 commits into
mainfrom
jwt-issuer
Aug 28, 2026
Merged

Judge a JWT by its issuer, not its shape#41
haruotsu merged 3 commits into
mainfrom
jwt-issuer

Conversation

@haruotsu

Copy link
Copy Markdown
Collaborator

Exchanging an authorization code against a Zendesk tenant returns a
JWT-shaped access token: three dot-separated segments, a base64url JSON
header, and claims that carry only exp — no issuer. accepts_token
refused every JWT-shaped token for a prefixless connection, so a Zendesk
authorization could never produce a usable token.

Why shape cannot convict

The refusal exists so that a Google ID token or service account token
handed over by mistake is never sent to an external service. Both are
JWTs — but so are many services' access tokens. "It is a JWT" does not
imply "it is Google's", and the two kinds tell themselves apart by
something no shape check can see: the issuer the claims name.

What is judged now

For a connection without token prefixes (a declared prefix still decides
on its own):

  • A JWT that names an issuer is accepted only when the issuer names
    the connection's own service — a host the token may travel to, or the
    host of an OAuth endpoint, since the issuer of a service's tokens is
    its authorization server, which may live beside the API rather than on
    it.
  • A JWT that names no issuer says as little about its origin as an
    opaque token, so it is judged like one: by elimination. Zendesk's
    tokens are shaped this way.
  • A JWT whose claims cannot be read is refused rather than guessed
    about, exactly as before.

Google's credentials fall out of the first rule without a list of
Google's issuers: ID tokens name accounts.google.com, service account
tokens name the account's own address, and neither is ever the
connection's own host. The signature is deliberately not checked —
nothing here authenticates anyone. The claims are read the way a token
prefix is, to tell whose token this is before it travels; a forged
issuer gains nothing but a request its token cannot answer.

What holds it

  • The catalog check that read "a JWT-shaped token is accepted" now holds
    every connection against Google-issued JWTs (ID-token and
    service-account shaped) instead, since an issuerless JWT is exactly
    what a prefixless connection may now accept.
  • zendesk.yaml records the observed shape as an accepts example;
    freee and notion-mcp keep refusing Google-issued JWTs in theirs.
  • The issuer arrives inside an unvetted token, so one that urlsplit
    cannot read (https://[) is refused instead of raising.

The JWT-shape refusal exists to keep Google ID tokens and service
account tokens from being sent to a service they were never meant
for, but it read every three-segment token as one of Google's. OAuth
providers issue JWT-shaped access tokens of their own - Zendesk's
carry only exp and name no issuer - so the shape convicts nothing.

What a JWT names is what is judged now. One that names an issuer
must name the connection's own service (its hosts or OAuth
endpoints); one that names none says as little as an opaque token
and is judged by elimination like one; one that cannot be read is
refused rather than guessed about. Google's credentials still fall
out without a list of Google's issuers: ID tokens and service
account tokens always name theirs, which is never the connection's
own.

🤖 Generated with Claude Code
json.loads recurses per nesting level, so a payload of nothing but
brackets escaped the ValueError net as a RecursionError and blew up
the tool call instead of being refused. The claims arrive inside an
unvetted token; however they defeat the reader, the answer is
refusal, not a traceback.

A null issuer slipped the other way: claims.get("iss") read it as no
issuer at all, and the token went on to be judged by elimination.
RFC 7519 wants a StringOrURI wherever iss is present, so a
present-but-null issuer is as malformed as a numeric one and is
refused the same.

🤖 Generated with Claude Code
A prefixless connection declares no shapes, so a JWT refused for its
issuer was logged as matching none of them - true, but a mystery to
whoever reads the warning, and the issuer is exactly what needs
diagnosing when a service's tokens change under a connection. The
description now names what refused the token: claims that cannot be
read, or an issuer that does not name this service. The token itself
still never reaches the log.

🤖 Generated with Claude Code
@haruotsu
haruotsu merged commit bd9657b into main Aug 28, 2026
3 checks passed
@haruotsu
haruotsu deleted the jwt-issuer branch August 28, 2026 02:15
@github-actions github-actions Bot mentioned this pull request Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant