cd /news/developer-tools/deal-scoped-paseto-three-things-i-go… · home topics developer-tools article
[ARTICLE · art-83909] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Deal-scoped PASETO: three things I got wrong about audience-bound tokens

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.

read10 min views4 publishedAug 2, 2026

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.

Three normative sentences from the 2026-07-28 authorization spec, which is the current revision as I write this. They are from across its Token Handling section rather than one continuous paragraph:

MCP 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.

The middle sentence is the one that moved. In 2025-06-18 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.

That 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.

The 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

. Not whether they are a valid user of the platform. Not whether they are a paying customer. This deal.

That 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.

Signing is one line, and the version is chosen at compile time rather than read out of the token:

// V4Sign returns a plain string; it panics only on internal encoding errors.
signed := tok.V4Sign(kp.PrivateKey, nil)

No v4.local, no JWT, no algorithm negotiation. There is no alg

header 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

, needs a negotiable algorithm field in order to exist. PASETO's contribution is not better cryptography. It is deleting the negotiation.

I 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.

The 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.

It 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.

The fix is to stop naming keys and start fingerprinting them:

const kidHexLen = 16

func DeriveKid(pub paseto.V4AsymmetricPublicKey) string {
    sum := sha256.Sum256(pub.ExportBytes())
    return hex.EncodeToString(sum[:])[:kidHexLen]
}

And so the mint path cannot disagree with itself, the derives the kid instead of accepting one from its caller:

func LoadKeyPair(privKeyHex string) (KeyPair, error) {
    sk, err := paseto.NewV4AsymmetricSecretKeyFromHex(privKeyHex)
    if err != nil {
        return KeyPair{}, fmt.Errorf("credentials: load key pair: %w", err)
    }
    pub := sk.Public()
    return KeyPair{
        PrivateKey: sk,
        PublicKey:  pub,
        Kid:        DeriveKid(pub),
    }, nil
}

A 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.

Yes, 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.

Deriving 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:

// ExtractKid reads the key ID from the token footer WITHOUT verifying the
// signature. This is intentionally unsafe and is used only for key selection
// before the full Verify call.
func ExtractKid(token string) (string, error) {
    parser := paseto.NewParserWithoutExpiryCheck()
    footerBytes, err := parser.UnsafeParseFooter(paseto.V4Public, token)
    // ...unmarshal, reject an empty kid
}

go-paseto calls that method UnsafeParseFooter

and 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.

That 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.

The 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.

Callers need two different answers out of a failed verification. ErrExpiredToken

is a routine event with a routine remedy. ErrInvalidToken

means someone presented a credential I did not sign. Those belong in different places, and one of them should page someone.

go-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:

// The version I wrote first, and deleted.
if strings.Contains(err.Error(), "expired") {
    return ErrExpiredToken
}

It 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.

The version that ships turns the rule off and does the comparison itself:

// Parse without the built-in NotExpired rule so a valid-signature but expired
// token parses successfully and we can classify it deterministically below.
parser := paseto.NewParserWithoutExpiryCheck()

tok, err := parser.ParseV4Public(pubKey, token, nil)
if err != nil {
    return TokenClaims{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
}

// Enforce not-before explicitly: a token whose nbf is in the future is not yet
// valid and must not be accepted.
if nbf, nbfErr := tok.GetNotBefore(); nbfErr == nil && time.Now().Before(nbf) {
    return TokenClaims{}, fmt.Errorf("%w: token not yet valid", ErrInvalidToken)
}

// Classify expiry directly off the exp claim.
if exp, expErr := tok.GetExpiration(); expErr == nil && !time.Now().Before(exp) {
    return TokenClaims{}, ErrExpiredToken
}

Signature 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

is a statement about a token I definitely signed. Then the temporal checks, in code I own, comparing values instead of strings.

The 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()

preloads exactly one rule, NotExpired()

, and that rule reads exp

and nothing else. nbf

has its own rule, NotBeforeNbf()

, and it is not loaded by default. ValidAt()

checks iat

, nbf

and exp

together, and you only get it from NewParserForValidNow()

.

So that check is not compensating for something I switched off. NewParserWithoutExpiryCheck

dropped the one rule I was already replacing. The nbf

gap 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

sounds like sensible defaults. It is a one-element slice, and the element is not the one you assumed.

This 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.

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

at deal finalize and LoadKeyPair

at startup. Nothing on the serving path verifies anything.

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.

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.

Put 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.

Which is exactly why the MCP spec does not stop at audience binding. The same document makes discovery a requirement in its own right:

MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728). MCP clients MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery.

Next 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.

The fix has a shape, and I will write it up separately once it ships:

And 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.

This is running at 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/deal-scoped-paseto-t…] indexed:0 read:10min 2026-08-02 ·