Skip to main content

OAuth Access Tokens

Version requirement

OAuth access-token issuance requires the Trust Domain Server (spirl-server) 0.38.0 or later.

Defakto issues SPIFFE credentials today: X.509-SVIDs, JWT-SVIDs, and WIT-SVIDs. This page describes something different: exchanging a JWT-SVID your workload already holds for a standard OAuth 2.0 access token.

This token is not a SPIFFE SVID. It is a plain OAuth access token (RFC 9068 at+jwt), meant for calling services that speak OAuth, most notably Model Context Protocol (MCP) servers, which expect a bearer access token rather than a JWT-SVID.

A workload presents a self-issued JWT-SVID as the assertion on the Trust Domain Server's /oauth/token endpoint, using the RFC 7523 jwt-bearer grant. In return it receives a freshly minted OAuth access token, signed by a dedicated OAuth signing key and issued under a dedicated OAuth issuer identity that is separate from your JWT-SVID issuer.

This implements the jwt-bearer leg of MCP's Workload Identity Federation profile, SEP-1933, which defines how a workload-issued JWT is exchanged for an access token an MCP server accepts.

How it works

The workload presents a JWT-SVID as the assertion, and may optionally authenticate as itself with a client_assertion. The Trust Domain Server verifies the assertion against your trust domain's own JWT-SVID keys, and then issues the access token.

Key points for your workloads:

  • The workload presents a self-issued JWT-SVID as the assertion. Self-issued means your own trust domain issued it. The Trust Domain Server verifies it against its own JWT-SVID signing keys, with no outbound OIDC discovery.
  • Both the assertion and any client_assertion must carry aud set to the OAuth issuer (the authorization-server identifier, {jwt-issuer}/oauth by default). Fetch the JWT-SVID with its --audience set to the OAuth issuer.
  • Client authentication is optional. Per RFC 7523 §2.1 and SEP-1933, a conformant request may send just the assertion. If you additionally send a client_assertion (a JWT-SVID, using client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-spiffe, with the same aud), the Trust Domain Server verifies it and requires the assertion's sub to equal that authenticated client. This rejects callers that lack a separate valid client assertion for the same subject; it does not prevent replay by anyone holding the bearer assertion. Sending the same JWT-SVID in both fields does not add proof of possession.
  • Without a client_assertion, the assertion's own signature, iss, and aud are the sole check. Because JWT-SVIDs are bearer tokens, a leaked assertion could be exchanged by whoever holds it. Protect the assertion accordingly.
  • The output access_token is a bearer token by default. Protect it like any bearer credential, or send a DPoP proof to have it bound to a key you hold, so the token alone is not enough to use it. See DPoP: sender-constrained tokens.

The request

POST /oauth/token with Content-Type: application/x-www-form-urlencoded:

ParameterRequiredNotes
grant_typeYesurn:ietf:params:oauth:grant-type:jwt-bearer
assertionYesA single self-issued JWT-SVID (compact JWS). This is the credential being exchanged.
resourceYes (or audience)The target resource or MCP server the token is for (RFC 8707). You supply this, and it becomes the access token's aud claim verbatim. Single value.
audienceAccepted as an alternative to resource. Supply one of resource or audience, not both. Single value; repeats are rejected.
scopeNoSpace-delimited OAuth scope. Copied onto the token, if present.
client_assertion_typeNourn:ietf:params:oauth:client-assertion-type:jwt-spiffe. Only when supplying client authentication.
client_assertionNoA JWT-SVID authenticating the calling workload. Optional. See the client-authentication note above.

The response

A success returns an RFC 6749 §5.1 JSON body:

{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtzXzNGdVFKNGxJYUhNanZrYzZ...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "read"
}

token_type is Bearer for an ordinary request. Send a DPoP proof and token_type is DPoP instead, signalling that the token must be presented with a proof. See DPoP: sender-constrained tokens.

What's in the access token

The access token is a signed JWT following RFC 9068:

Claim or headerValue
typ (header)at+jwt, marking this as an OAuth access token, not a JWT-SVID
alg / kid (header)RS256 and the OAuth signing key's kid (from the OAuth JWKS)
issThe dedicated OAuth issuer, {jwt-issuer}/oauth, distinct from the JWT-SVID issuer
subThe calling workload's SPIFFE ID
client_idThe authenticated client; equals sub in this self-issued flow
audThe resource or audience you requested (the target resource or MCP server)
scopeCopied from the request, if present
jtiUnique token ID, stamped at issuance
iat / expIssued-at and expiry, stamped by the Trust Domain Server
cnfPresent only on a DPoP-bound token: {"jkt": "<thumbprint>"}, the RFC 7638 thumbprint of the key you proved possession of (RFC 7800). Absent for a bearer token.

Example decoded payload:

{
"iss": "https://fed.prod.spirl.org/t-acme/td-01/oauth",
"sub": "spiffe://acme.example/mcp-client",
"aud": "https://mcp.example.com",
"client_id": "spiffe://acme.example/mcp-client",
"scope": "read",
"jti": "e311bd50311d5c936825e3e7b422a9ba",
"iat": 1750000000,
"exp": 1750000300
}

Token lifetime

The access token uses a short default lifetime capped at the assertion's own expiry, so the token can never outlive the original JWT-SVID. The default lifetime is 5 minutes, and the maximum is 15 minutes. These are not configurable. Because JWT-SVIDs are themselves short-lived, access tokens are short by construction. If the assertion has already expired, the request is rejected with invalid_grant rather than issuing a token.

DPoP: sender-constrained tokens

Version requirement

DPoP support requires the Trust Domain Server (spirl-server) 0.39.0 or later. The jwt-bearer grant itself is available from 0.38.0.

An access token is a bearer credential, so if it leaks from a log, a proxy, or an over-broad cache, whoever holds the string has your workload's authority, and nothing in the token distinguishes them from your workload.

DPoP (Demonstrating Proof-of-Possession, RFC 9449) closes that gap. Your workload generates its own key pair and proves possession of the private key on the token request. The Trust Domain Server binds the issued token to that key by stamping the key's thumbprint into the token's cnf claim, so the token alone is no longer enough: The holder must also produce a fresh signature from the bound key.

MCP adopts DPoP in SEP-1932, which is why it is offered here. DPoP is generic OAuth, though, not MCP-specific.

Three things DPoP is not:

  • Not a new credential type. The issued token is the same RFC 9068 at+jwt access token, with the same signing key, issuer, and discovery chain, and DPoP adds a single claim to it.
  • Not a new signing key. Two unrelated keys are in play: Your key signs the proof, and the existing OAuth signing key signs the token, so there is nothing to provision.
  • Not the Trust Domain Server's job at presentation time. When you present the token, the MCP server compares your proof against the token's cnf.jkt. Under RFC 9449 §7 that check is the resource server's job. The Trust Domain Server's role ends at issuance.

Opt-in, never required

Send a proof and the token is bound. Send none and you get exactly the token you get today, with token_type: Bearer and no cnf claim. Clients that do not use DPoP are entirely unaffected.

Support is advertised in the RFC 8414 authorization-server metadata as dpop_signing_alg_values_supported, listing ES256 and RS256, so a client can discover it rather than being configured for it. See Testing the endpoint for the discovery request that returns it.

The proof

The DPoP request header carries a compact JWS you sign with your own key. The proof is not an SVID, and not anything the Trust Domain Server issues.

Header:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}

Claims:

{
"jti": "e1j3V_bKic8-LAEB",
"htm": "POST",
"htu": "https://fed.prod.spirl.org/t-acme/td-01/oauth/token",
"iat": 1750000000
}

The embedded jwk is the public half of your key, so nothing needs pre-registering. The htm and htu claims bind the proof to this method and URL, so a proof captured for one endpoint cannot be replayed at another.

FieldRequirement
typ (header)Exactly dpop+jwt.
alg (header)ES256 or RS256. MACs and none are rejected, since a symmetric "proof" proves nothing about key possession. RSA keys must be 2048 to 4096 bits.
jwk (header)The public key you are proving. A private component is rejected.
jtiUnique per proof, at most 255 characters. Used once.
htmThe request method, POST.
htuThe token endpoint URL. See important tips in the note below.
iatWithin the acceptance window: up to 1 minute old, and no more than 5 seconds in the future (clock skew only).
DPoP header countExactly one. Two headers are ambiguous and are rejected.
Getting htu right

htu is the token endpoint: your OAuth issuer with /token appended, which is what the authorization-server metadata publishes as token_endpoint. A mismatched htu is the most common cause of invalid_dpop_proof:

  • It is not the bare OAuth issuer, and not the MCP server. Read token_endpoint from {oauth-iss}/.well-known/oauth-authorization-server and use that value.
  • It is compared after normalization (RFC 3986 §6.2.2 to §6.2.3, per RFC 9449 §4.3). Scheme and host are case-insensitive and a default port is dropped, so https://HOST:443/oauth/token and https://host/oauth/token match. Query and fragment are ignored. Path case is significant.

Replay protection

Each proof's jti is consumed once, and only after your request is otherwise authenticated and authorized, so a rejected request does not invalidate the jti and a legitimate retry is never refused as a replay. Reusing a jti inside the acceptance window is rejected, however.

The replay cache is implemented per server replica, so a captured proof could in principle be replayed once per replica within its short iat window. This is the same posture client assertions already ship with.

Replaying a proof requires capturing the whole authenticated request, since the proof alone issues nothing without the JWT-SVID it accompanied. A replayed request issues a token bound to the captured proof's key, and the attacker does not hold that key's private half, so a resource server rejects the token under RFC 9449 §7.1. Without DPoP the same captured request is replayable without limit, against every replica, for the assertion's whole lifetime.

The acceptance window is not a tuning knob for this. A shorter window does not reduce the one-replay-per-replica bound, it only compresses the time those attempts must fit into, and the same setting bounds how far in the past a proof's iat may sit, so tightening it starts rejecting legitimate proofs from clients whose clocks are slightly off.

The DPoP response

{
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJSUzI1NiIsImtpZCI6Im9hdXRoLTAxIn0...",
"token_type": "DPoP",
"expires_in": 300,
"scope": "read"
}

token_type is DPoP rather than Bearer, which is your signal that the token must be presented with a proof. The decoded token carries one extra claim:

"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }

jkt is the RFC 7638 SHA-256 thumbprint of the public key from your proof's jwk. The thumbprint hashes a public key, so it carries no secret and is safe in logs. Everything else about the token is unchanged.

Requesting a bound token with spirldbg

spirldbg oauth-token --dpop generates an ES256 key, signs a proof, and sends it, so you can exercise the binding without hand-building a JWS:

spirldbg oauth-token \
--token-endpoint "$SERVER/oauth/token" \
--resource "$RESOURCE" \
--dpop
# expect: Token type: DPoP, and a "Key binding (cnf.jkt)" line

The key is generated per invocation and is not retained, so the resulting token cannot then be presented to a resource server. This is deliberate, since the flag exercises issuance rather than presentation.

Left to itself, spirldbg discovers the authorization-server identifier by fetching a throwaway JWT-SVID and reading its iss claim, then appending the /oauth sub-path. It builds the proof's htu from that identifier plus /token, which is the published token_endpoint. Passing --jwt-svid skips that discovery fetch, leaving no identifier to build htu from, so combine --jwt-svid with --authorization-server:

spirldbg oauth-token \
--token-endpoint "$SERVER/oauth/token" \
--resource "$RESOURCE" \
--jwt-svid "$(cat "$SVID")" \
--authorization-server "$OAUTH_ISS" \
--dpop

--jwt-svid takes the SVID itself rather than a path, so this form puts a bearer credential in the process argument list, where any local user can read it with ps. Use it for local debugging only. The curl flow above reads the SVID from a file precisely to avoid this.

htu is built from the authorization-server identifier rather than from --token-endpoint because the two are not always the same URL. --token-endpoint is whatever address you can reach the server on, which may be a direct pod address or an --insecure host, while htu must name the endpoint's public identity as published in the metadata. Where you connect directly to the public endpoint, they coincide.

Presenting a bound token

Presentation is the resource server's side rather than the Trust Domain Server's, but for completeness: RFC 9449 §7 has you send the token as Authorization: DPoP <token> rather than Bearer, plus a fresh proof for that request, with its own jti and with htm and htu naming the resource server's URL. The resource server compares your proof's key thumbprint to the token's cnf.jkt.

Per RFC 9449 §7.1, a resource server that sees cnf must reject the token if it is presented without a valid matching proof. A binding is permanent, because cnf sits inside the signed token and cannot be stripped without breaking the signature. A token bound at issuance never becomes an ordinary bearer token afterwards.

Not yet supported

  • The RFC 8693 token-exchange (OBO) grant. A token-exchange request carrying a DPoP header is rejected with invalid_dpop_proof rather than silently served as a bearer token, since otherwise you would hold a token you believed was bound but was not. Binding OBO tokens is future work, so drop the header to use that grant.
  • DPoP-Nonce and use_dpop_nonce challenges (RFC 9449 §8). These are not implemented, and the server never demands a nonce.

Signing keys and discovery

OAuth access tokens are signed with a dedicated OAuth signing key (RSA-4096, RS256), separate from the X.509, JWT, and WIT signing keys. This keeps the credential classes cryptographically isolated and lets the OAuth key rotate independently as part of the normal key-set lifecycle.

The OAuth issuer has its own OIDC discovery document and JWKS, published to the federation endpoint and distinct from the JWT-SVID issuer's. By default it sits under an /oauth sub-path of the JWT-SVID issuer:

JWT-SVIDOAuth access token
Issuer (iss){jwt-issuer}{jwt-issuer}/oauth
Discovery{iss}/.well-known/openid-configuration{oauth-iss}/.well-known/openid-configuration
JWKS{iss}/jwks{oauth-iss}/jwks
Authorization-server metadata{oauth-iss}/.well-known/oauth-authorization-server (RFC 8414)

A relying party such as an MCP server verifies an access token by fetching {oauth-iss}/.well-known/openid-configuration, following jwks_uri to the OAuth JWKS, and validating the signature and aud. The distinct issuer is deliberate: A relying party performing discovery on the OAuth issuer can never confuse an OAuth access token with a JWT-SVID.

Testing the endpoint

You can exercise /oauth/token end to end with spirldbg (to fetch the JWT-SVID through the Agent's Workload API, exactly as a real workload would) and curl. The following prerequisites must be met:

  1. The Trust Domain Server is running 0.38.0 or later.
  2. A key set with an OAuth key is active (see Setting it up).
  3. Your workload has a JWT-SVID issuance policy.
  4. spirldbg can reach the Agent's Workload API socket.

The minimal SEP-1933-conformant request is grant_type + assertion + resource: one JWT-SVID, no client authentication.

The one thing to get right

The assertion (the JWT-SVID) must have its aud set to your trust domain's OAuth issuer (the OIDC issuer with /oauth appended by default). The MCP server is named separately, in the resource form field. Fetching the JWT-SVID with the wrong --audience is the most common cause of invalid_grant.

1. Find your OAuth issuer. In the next step you fetch the JWT-SVID with its --audience set to this value, so its aud claim matches what the endpoint expects:

spirlctl trust-domain info example.com --output json | jq -r '.oauth_issuer.issuer'
# e.g. https://fed.prod.spirl.org/t-acme/td-01/oauth

2. Fetch a JWT-SVID with spirldbg, with --audience set to that OAuth issuer. spirldbg svid-jwt fetches through the Agent's Workload API socket; --raw prints the bare compact JWT:

The JWT-SVID is a bearer credential, so write it to a private temporary file rather than the working directory, and remove it when done. Keeping it in a file (instead of a shell variable) also keeps it out of the process argument list in the next step.

OAUTH_ISS=$(spirlctl trust-domain info example.com --output json | jq -r '.oauth_issuer.issuer')
SOCKET=/run/spirl/agent/workload.sock # your Agent's Workload API socket

SVID=$(umask 077 && mktemp) # private temp file, owner-only
trap 'rm -f "$SVID"' EXIT # remove it on exit
spirldbg svid-jwt --spiffe-endpoint-socket "$SOCKET" --audience "$OAUTH_ISS" --raw > "$SVID"

3. Call /oauth/token. Point at your Trust Domain Server; the target MCP server goes in resource. The assertion@"$SVID" form makes curl read and URL-encode the JWT-SVID straight from the file, so it never appears in the command's arguments:

SERVER=https://your-td-server.example.com # the Trust Domain Server address
RESOURCE=https://mcp.example.com # the MCP server the token is for

curl -sS "$SERVER/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion@$SVID" \
--data-urlencode "resource=$RESOURCE" \
--data-urlencode "scope=read" | jq

To exercise the stricter path, in which the Trust Domain Server verifies the caller and binds the assertion's sub to it, also send a client_assertion. The same JWT-SVID works, since it resolves to the same SPIFFE ID:

--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" \
--data-urlencode "client_assertion@$SVID"

A success returns the JSON body shown in The response. Decode the access_token to confirm the claims:

# paste the access_token value:
echo '<access_token>' | jq -R 'split(".")[1] | @base64d | fromjson'
# expect: iss ending in /oauth, sub = your workload, aud = the MCP server

jq's @base64d decodes the URL-safe base64 of the JWT payload without the padding a plain base64 -d would need.

Doing all of this in one step

spirldbg oauth-token performs the whole exchange in one command. It fetches the assertion from the Workload API with the right aud and posts it to the endpoint for you:

spirldbg oauth-token --token-endpoint "$SERVER/oauth/token" --resource "$RESOURCE"

Add --client-auth to also send a client_assertion (a second JWT-SVID, fetched separately so its jti is distinct from the grant assertion's). The curl flow above is shown here so the request form fields and JSON response are explicit.

4. (Optional) Verify discoverability the way a relying party would, by fetching the OAuth issuer's discovery documents and JWKS:

curl -sS "$OAUTH_ISS/.well-known/openid-configuration" | jq # issuer, jwks_uri, signing algs
curl -sS "$OAUTH_ISS/.well-known/oauth-authorization-server" | jq # RFC 8414 metadata: token_endpoint, jwks_uri, dpop_signing_alg_values_supported
curl -sS "$OAUTH_ISS/jwks" | jq '.keys[].kid' # should include the access token's kid

If a call fails, the error in the JSON body tells you where. See Error responses. The usual culprit is a JWT-SVID fetched with the wrong --audience.

Fetching an access token in code

A workload obtains an access token by fetching a JWT-SVID whose aud is the OAuth issuer, then posting it as the assertion to /oauth/token. The examples below show the same exchange with curl and with Go using go-spiffe.

# The JWT-SVID's aud must be the OAuth issuer (see step 1 of Testing above):
OAUTH_ISS=$(spirlctl trust-domain info example.com --output json | jq -r '.oauth_issuer.issuer')
SOCKET=/run/spirl/agent/workload.sock # your Agent's Workload API socket
SERVER=https://your-td-server.example.com # the Trust Domain Server address
RESOURCE=https://mcp.example.com # the MCP server the token is for

# Fetch the assertion into a private temp file, then exchange it. The
# assertion@"$SVID" form keeps the JWT-SVID off disk in the working directory
# and out of the process arguments:
SVID=$(umask 077 && mktemp)
trap 'rm -f "$SVID"' EXIT
spirldbg svid-jwt --spiffe-endpoint-socket "$SOCKET" --audience "$OAUTH_ISS" --raw > "$SVID"

curl -sS "$SERVER/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion@$SVID" \
--data-urlencode "resource=$RESOURCE" \
--data-urlencode "scope=read" | jq -r '.access_token'

Setting it up

Provision the OAuth signing key

OAuth access tokens are signed with a dedicated OAuth signing key that lives on the trust domain deployment's active signing key set, separate from the X.509, JWT, and WIT keys. An OAuth key is added to a key set only when that key set is created on spirl-server 0.38.0 or later:

  • New trust domain deployments created on 0.38.0 or later get the OAuth key automatically. No action required.
  • Existing trust domain deployments whose active key set predates 0.38.0 have no OAuth key yet. You must create a new key set so it picks up the OAuth key.

Provision the OAuth key by preparing and activating a new signing key set for the deployment, using the standard self-service key-set rotation flow, as follows.

Step 1. Find your deployment name. Read the value from the Name column:

spirlctl trust-domain deployment list --trust-domain example.com
Name ID Configuration State Last Configured
us-west id1 UP_TO_DATE 2026-07-07T00:00:00Z

1 trust domain deployment found.

Step 2. Prepare a new key set (this generates a new signing key set that now includes the OAuth key):

spirlctl trust-domain deployment keyset prepare \
--trust-domain example.com \
--deployment-name us-west

Step 3. Find the new key set's ID:

spirlctl trust-domain deployment keyset list \
--trust-domain example.com \
--deployment-name us-west

Step 4. Activate it (use the key set ID from the previous step):

spirlctl trust-domain deployment keyset activate KEY_SET_ID \
--trust-domain example.com \
--deployment-name us-west

Once the new key set is active and propagates, OAuth token issuance works. The OAuth JWKS and discovery document are published to the federation endpoint automatically.

tip

If you prefer not to rotate manually, the OAuth key also appears at your next scheduled key-set rotation, at which point issuance begins working automatically.

Error responses

Errors follow the OAuth 2.0 { "error", "error_description" } shape:

Situationerror
Server older than 0.38.0 (grant not offered)unsupported_grant_type
Missing assertion; missing target (no resource or audience); both resource and audience; repeated resource or audienceinvalid_request
A client_assertion was supplied but failed to authenticateinvalid_client
Assertion rejected (bad signature, wrong audience, expired, foreign issuer)invalid_grant
Assertion sub does not equal the authenticated client (only when a client_assertion is supplied)invalid_grant
Assertion has no remaining lifetimeinvalid_grant
DPoP proof rejected: bad signature, wrong typ or alg, private material in jwk, htm or htu mismatch, iat outside the window, a missing claim, or more than one DPoP headerinvalid_dpop_proof
DPoP proof jti already used (replay)invalid_dpop_proof
A DPoP header sent on the RFC 8693 token-exchange (OBO) grantinvalid_dpop_proof

Frequently asked questions

Is this a SPIFFE SVID? No. The output is a plain OAuth 2.0 access token (RFC 9068 at+jwt), for calling OAuth-speaking services like MCP servers. Your X.509-SVIDs, JWT-SVIDs, and WIT-SVIDs are unchanged.

Why does the OAuth token have a different issuer than my JWT-SVIDs? This is a deliberate design choice. If the access token shared the JWT-SVID issuer and JWKS, a relying party doing discovery could not cryptographically distinguish an access token from a JWT-SVID. A distinct issuer and distinct signing key make the two credential classes independently discoverable and allow for independent rotation.

Does my MCP client need to authenticate (send a client_assertion)? No. Per RFC 7523, the assertion alone is sufficient; a conformant request is just grant_type + assertion + resource. If you do send a client_assertion, the Trust Domain Server additionally binds the assertion's sub to it, so a copied JWT-SVID (assertion) cannot be exchanged unless the caller can also present a valid client_assertion for the same subject.

My MCP client sends resource= (RFC 8707); does it work? Yes. resource is the parameter SEP-1933 requires, and the Trust Domain Server accepts it as the target. The audience parameter is also accepted as an alternative; send one, not both.

How long is the token valid? Short by default (5 minutes), never more than 15 minutes, and never longer than the original JWT-SVID. These lifetimes cannot be configured.

Do I have to use DPoP? No. DPoP is opt-in, so if you send no proof you get the same bearer token as today, with no cnf claim.

What breaks if I start sending DPoP proofs? Nothing on the Trust Domain Server side, but check your resource server. Per RFC 9449 §7.1, a server that sees cnf must reject the token unless a valid matching proof accompanies it. A bound token presented to a DPoP-aware server without a proof will fail where a bearer token would have worked. A server that ignores cnf accepts the token as an ordinary bearer token, which silently defeats the binding. Confirm your MCP server supports DPoP before switching.

Can I use DPoP with the OBO (token-exchange) grant? Not yet. A token-exchange request carrying a DPoP header is rejected rather than quietly returning an unbound token, so you can never hold a token you wrongly believe is sender-constrained. Drop the header for that grant.

Where do I get a DPoP proof implementation? MCP clients get proof generation from the MCP SDKs. For plain workloads, build the proof with any JOSE library. The proof is a small JWS, shown in The proof. For testing, spirldbg oauth-token --dpop generates one.

Do I need a new key or any setup for DPoP? No. Your workload generates its own key pair, nothing is registered with the Trust Domain Server, and no Defakto signing key material changes. The same OAuth signing key signs the token either way.