{"slug": "deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens", "title": "Deal-scoped PASETO: three things I got wrong about audience-bound tokens", "summary": "A developer building a deal-scoped PASETO system for the Model Context Protocol describes three mistakes made when implementing audience-bound tokens. The system mints credentials scoped to a single two-party transaction, and the developer details how key rotation and token validation were corrected. The third issue remains unfixed.", "body_md": "The Model Context Protocol's authorization section says something blunt: a server must reject any token that was not issued for it. That has been normative since the 2025-06-18 revision rather than arriving with the current one, which is worth saying plainly because the interesting question was never whether the rule is new. I have been running a system where every credential is scoped to exactly one two-party transaction, so I want to write down what that actually took, including the third thing I still have not fixed.\n\nThree normative sentences from the [2026-07-28 authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), which is the current revision as I write this. They are from across its Token Handling section rather than one continuous paragraph:\n\nMCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. [...] MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.\n\nThe middle sentence is the one that moved. In [2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) it began *Authorization servers* MUST only accept tokens, which put the obligation on the party that issues tokens in a passage about the party that receives them. The current revision points it at MCP servers. The first and third sentences are unchanged from 2025-06-18.\n\nThat is three sentences in a spec and about a quarter of the work in an implementation. The rest of this post is the other three quarters.\n\nThe system brokers work between two agents that stay pseudonymous while they negotiate. When a deal seals, each side receives the other's callback endpoint and a credential, and the broker steps out of the data path. The credential has exactly one job at the receiving end: answer whether this caller belongs to deal `1a2b3c4d`\n\n. Not whether they are a valid user of the platform. Not whether they are a paying customer. This deal.\n\nThat is the same shape as the MCP requirement. A token minted for one resource has to be worthless at every other one. The only difference is that my resource is a transaction rather than a server.\n\nSigning is one line, and the version is chosen at compile time rather than read out of the token:\n\n```\n// V4Sign returns a plain string; it panics only on internal encoding errors.\nsigned := tok.V4Sign(kp.PrivateKey, nil)\n```\n\nNo v4.local, no JWT, no algorithm negotiation. There is no `alg`\n\nheader for an attacker to rewrite because there is no header. The whole family of algorithm-confusion bugs, where a verifier is talked into treating an RSA public key as an HMAC secret or into honouring `alg: none`\n\n, needs a negotiable algorithm field in order to exist. PASETO's contribution is not better cryptography. It is deleting the negotiation.\n\nI am not going to pretend that was a hard decision. It took ten minutes and it removed a category. The next three took considerably longer.\n\nThe first version put a fixed string in the token footer as the key identifier, something like the deployment stage name. It parsed, it round-tripped, the tests were green.\n\nIt also meant that rotating the signing key was a hard cutover. These credentials live seven days. Rotate on a Tuesday and every credential minted in the previous seven days carries a kid that now resolves to the wrong key. There is no window where the old key and the new key are both selectable, because both of them answer to the same name.\n\nThe fix is to stop naming keys and start fingerprinting them:\n\n``` js\nconst kidHexLen = 16\n\nfunc DeriveKid(pub paseto.V4AsymmetricPublicKey) string {\n    sum := sha256.Sum256(pub.ExportBytes())\n    return hex.EncodeToString(sum[:])[:kidHexLen]\n}\n```\n\nAnd so the mint path cannot disagree with itself, the loader derives the kid instead of accepting one from its caller:\n\n```\nfunc LoadKeyPair(privKeyHex string) (KeyPair, error) {\n    sk, err := paseto.NewV4AsymmetricSecretKeyFromHex(privKeyHex)\n    if err != nil {\n        return KeyPair{}, fmt.Errorf(\"credentials: load key pair: %w\", err)\n    }\n    pub := sk.Public()\n    return KeyPair{\n        PrivateKey: sk,\n        PublicKey:  pub,\n        Kid:        DeriveKid(pub),\n    }, nil\n}\n```\n\nA kid is now a property of the key rather than a label attached to it. Two keys can be live at once, a verifier picks by kid, and rotation is an overlap window instead of an outage. The old key stays loaded until the last credential it signed has expired, which is a bounded seven days, and then it goes away.\n\nYes, sixteen hex characters is a truncated hash. It is a selector, not a security boundary. The kid tells a verifier which key to try; the signature decides whether the token is real. If someone finds a second public key whose SHA-256 shares its first eight bytes with mine, they have successfully selected my key and still cannot sign with it. A colliding kid fails verification. It does not forge one.\n\nDeriving the kid is only half of it. Reading it back is the other half, and it is the one place where a token has to be touched before its signature is checked:\n\n```\n// ExtractKid reads the key ID from the token footer WITHOUT verifying the\n// signature. This is intentionally unsafe and is used only for key selection\n// before the full Verify call.\nfunc ExtractKid(token string) (string, error) {\n    parser := paseto.NewParserWithoutExpiryCheck()\n    footerBytes, err := parser.UnsafeParseFooter(paseto.V4Public, token)\n    // ...unmarshal, reject an empty kid\n}\n```\n\ngo-paseto calls that method `UnsafeParseFooter`\n\nand the name is honest: nothing has been authenticated at that point. What makes it safe anyway is that the footer is covered by the signature, because PASETO folds it into the pre-authentication encoding. A tampered kid cannot do anything except point at the wrong key, and the wrong key fails verification. The kid is a hint about which key to try, never a claim to be believed.\n\nThat distinction is worth being explicit about, because *select a key using the token, then verify the token with that key* reads as circular until you notice the selector lives inside the authenticated envelope. The rule it generalises to: you may read unauthenticated input to decide **how** to check something, never to decide **whether** it passed.\n\nThe generalisable part: an identifier that has to survive rotation should be derived from the thing it identifies. Choose it independently and the two can drift, and the drift surfaces at precisely the moment you are rotating a key under pressure.\n\nCallers need two different answers out of a failed verification. `ErrExpiredToken`\n\nis a routine event with a routine remedy. `ErrInvalidToken`\n\nmeans someone presented a credential I did not sign. Those belong in different places, and one of them should page someone.\n\ngo-paseto validates temporal claims through rules, so the obvious implementation parses with the not-expired rule on and inspects whatever error comes back. In practice that means this:\n\n```\n// The version I wrote first, and deleted.\nif strings.Contains(err.Error(), \"expired\") {\n    return ErrExpiredToken\n}\n```\n\nIt works, and it is coupled to a string in someone else's repository that they never promised not to change. A minor version bump that rewords a rule error does not fail the build, does not fail the type check, and does not fail any test that builds its expired token through the same library. It silently reclassifies every expired credential as a forgery. Nothing breaks visibly. A routine event just starts arriving on the path reserved for attacks, which is how a real one gets ignored.\n\nThe version that ships turns the rule off and does the comparison itself:\n\n```\n// Parse without the built-in NotExpired rule so a valid-signature but expired\n// token parses successfully and we can classify it deterministically below.\nparser := paseto.NewParserWithoutExpiryCheck()\n\ntok, err := parser.ParseV4Public(pubKey, token, nil)\nif err != nil {\n    return TokenClaims{}, fmt.Errorf(\"%w: %v\", ErrInvalidToken, err)\n}\n\n// Enforce not-before explicitly: a token whose nbf is in the future is not yet\n// valid and must not be accepted.\nif nbf, nbfErr := tok.GetNotBefore(); nbfErr == nil && time.Now().Before(nbf) {\n    return TokenClaims{}, fmt.Errorf(\"%w: token not yet valid\", ErrInvalidToken)\n}\n\n// Classify expiry directly off the exp claim.\nif exp, expErr := tok.GetExpiration(); expErr == nil && !time.Now().Before(exp) {\n    return TokenClaims{}, ErrExpiredToken\n}\n```\n\nSignature first, always. The kid lookup above is the only thing that touches the token earlier, and it decides nothing. Only a cryptographically valid token gets its claims read, so `expired`\n\nis a statement about a token I definitely signed. Then the temporal checks, in code I own, comparing values instead of strings.\n\nThe explicit not-before check in the middle is the part I would have gotten wrong if I had trusted the constructor name, so it is worth being precise about what go-paseto v1.6.0 actually does. `NewParser()`\n\npreloads exactly one rule, `NotExpired()`\n\n, and that rule reads `exp`\n\nand nothing else. `nbf`\n\nhas its own rule, `NotBeforeNbf()`\n\n, and it is not loaded by default. `ValidAt()`\n\nchecks `iat`\n\n, `nbf`\n\nand `exp`\n\ntogether, and you only get it from `NewParserForValidNow()`\n\n.\n\nSo that check is not compensating for something I switched off. `NewParserWithoutExpiryCheck`\n\ndropped the one rule I was already replacing. The `nbf`\n\ngap was open the whole time, in the default parser, before I touched anything - I just found it while reading the rule list instead of the constructor names. A parser named `NewParser`\n\nsounds like sensible defaults. It is a one-element slice, and the element is not the one you assumed.\n\nThis is the one I do not have a fix for yet, and it is the one that matters most, so here it is plainly. Three facts about the code as it stands today.\n\n**Verify has no production callers.** It exists, it is tested, and if you search the repository for callers outside the test files you get nothing. Production calls `Mint`\n\nat deal finalize and `LoadKeyPair`\n\nat startup. Nothing on the serving path verifies anything.\n\n**No endpoint publishes the public key.** There is no kid-to-key document anywhere on the API. A counterparty that receives a credential and reads its footer gets a key identifier that identifies nothing they can fetch. The only way to check a credential today is to ask me, which means the credential proves what I say it proves.\n\n**Both parties get the same token.** Finalize mints once and hands the identical string to both sides. It carries the deal ID and both pseudonymous references, so it proves membership of the deal. It cannot distinguish which member is holding it.\n\nPut those together and the honest description of what I built is: a correctly minted, correctly scoped, correctly rotatable credential that nobody can independently verify and that does not identify its bearer. The minting half is right. The half that converts minting into a security property is missing.\n\nWhich is exactly why the MCP spec does not stop at audience binding. The same document makes discovery a requirement in its own right:\n\nMCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728). MCP clients MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery.\n\nNext to the audience rule, that reads like plumbing. It is not. Without a published, fetchable way to learn which authority signs for a resource and which keys it is using, *this token was issued for you* degrades into *the issuer says this token was issued for you*. Discovery is the part that makes audience binding checkable by the party the binding is supposed to protect.\n\nThe fix has a shape, and I will write it up separately once it ships:\n\nAnd one thing I would do earlier: build the verifier before the issuer. Minting a token is satisfying and provides no security by itself. Everything that makes a credential worth holding lives on the side that checks it, and it is very easy to ship the satisfying half, watch the tests go green, and never notice that the other half was never written.\n\nThis is running at [cogdepot.com](https://cogdepot.com), where credentials are minted at deal finalize and each side gets a per-deal endpoint for the other. If you want to argue with any of the above, section three is the one I would argue with too.", "url": "https://wpnews.pro/news/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens", "canonical_source": "https://dev.to/akashy/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens-57p", "published_at": "2026-08-02 19:18:48+00:00", "updated_at": "2026-08-02 19:43:42.751716+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Model Context Protocol", "PASETO"], "alternates": {"html": "https://wpnews.pro/news/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens", "markdown": "https://wpnews.pro/news/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens.md", "text": "https://wpnews.pro/news/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens.txt", "jsonld": "https://wpnews.pro/news/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens.jsonld"}}