Skip to main content

OAuth Access Tokens

Preview feature

OAuth access-token issuance is a forward-looking feature and is off by default. It is enabled per organization on request. Contact Defakto support to enable it for your organization. The feature implements the jwt-bearer leg of an evolving authorization specification that may still change, so do not depend on it in production without coordinating with Defakto.

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. Protect it like any bearer credential.

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"
}

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

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.

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 feature is enabled for your organization.
  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.

note

spirldbg may provide an oauth-token command that performs the whole exchange in one step (check spirldbg --help in your environment). When available, it fetches the assertion from the Workload API and posts it to the endpoint for you (spirldbg oauth-token --token-endpoint <url> --resource <target>). 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
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

1. Enable the feature

OAuth access-token issuance is enabled per organization and is off by default. Contact Defakto support to enable it. Until it is enabled, the /oauth/token endpoint rejects the jwt-bearer grant with unsupported_grant_type.

2. 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 after the feature is enabled:

  • New trust domain deployments created after the feature is enabled get the OAuth key automatically. No action required.
  • Existing trust domain deployments whose active key set predates the feature 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
Feature not enabled (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

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.