OAuth Access Tokens
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
assertionand anyclient_assertionmust carryaudset to the OAuth issuer (the authorization-server identifier,{jwt-issuer}/oauthby default). Fetch the JWT-SVID with its--audienceset 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 aclient_assertion(a JWT-SVID, usingclient_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-spiffe, with the sameaud), the Trust Domain Server verifies it and requires the assertion'ssubto 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, andaudare 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_tokenis a bearer token. Protect it like any bearer credential.
The request
POST /oauth/token with Content-Type: application/x-www-form-urlencoded:
| Parameter | Required | Notes |
|---|---|---|
grant_type | Yes | urn:ietf:params:oauth:grant-type:jwt-bearer |
assertion | Yes | A single self-issued JWT-SVID (compact JWS). This is the credential being exchanged. |
resource | Yes (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. |
audience | — | Accepted as an alternative to resource. Supply one of resource or audience, not both. Single value; repeats are rejected. |
scope | No | Space-delimited OAuth scope. Copied onto the token, if present. |
client_assertion_type | No | urn:ietf:params:oauth:client-assertion-type:jwt-spiffe. Only when supplying client authentication. |
client_assertion | No | A 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 header | Value |
|---|---|
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) |
iss | The dedicated OAuth issuer, {jwt-issuer}/oauth, distinct from the JWT-SVID issuer |
sub | The calling workload's SPIFFE ID |
client_id | The authenticated client; equals sub in this self-issued flow |
aud | The resource or audience you requested (the target resource or MCP server) |
scope | Copied from the request, if present |
jti | Unique token ID, stamped at issuance |
iat / exp | Issued-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-SVID | OAuth 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:
- The feature is enabled for your organization.
- A key set with an OAuth key is active (see Setting it up).
- Your workload has a JWT-SVID issuance policy.
spirldbgcan 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 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.
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.
- curl
- Go (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'
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/spiffe/go-spiffe/v2/svid/jwtsvid"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
const (
// The OAuth issuer (authorization-server identifier). The assertion's aud
// must equal this. By default it is your OIDC issuer with /oauth appended;
// read it from `spirlctl trust-domain info <td> --output json`.
oauthIssuer = "https://fed.prod.spirl.org/t-acme/td-01/oauth"
tokenEndpoint = "https://your-td-server.example.com/oauth/token"
resource = "https://mcp.example.com" // the MCP server the token is for
)
// tokenResponse is the RFC 6749 §5.1 success body.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Connect to the Defakto Agent socket via the SPIFFE Workload API.
// Uses the SPIFFE_ENDPOINT_SOCKET env var to locate the Agent socket.
source, err := workloadapi.NewJWTSource(ctx)
if err != nil {
log.Fatalf("failed to create JWT source: %v", err)
}
defer source.Close()
// Fetch a self-issued JWT-SVID with aud set to the OAuth issuer.
svid, err := source.FetchJWTSVID(ctx, jwtsvid.Params{Audience: oauthIssuer})
if err != nil {
log.Fatalf("failed to fetch JWT-SVID: %v", err)
}
// Exchange it for an OAuth access token via the RFC 7523 jwt-bearer grant.
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", svid.Marshal())
form.Set("resource", resource)
form.Set("scope", "read")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
strings.NewReader(form.Encode()))
if err != nil {
log.Fatalf("failed to build request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("token request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("token endpoint returned %s", resp.Status)
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
log.Fatalf("failed to decode response: %v", err)
}
// Call the MCP server with: Authorization: Bearer <tr.AccessToken>
fmt.Printf("access token received (expires in %ds)\n", tr.ExpiresIn)
}
Ensure SPIFFE_ENDPOINT_SOCKET is set to the Defakto Agent socket address. When using Kubernetes webhooks to inject the socket, SPIFFE_ENDPOINT_SOCKET is set automatically.
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.
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:
| Situation | error |
|---|---|
| Feature not enabled (grant not offered) | unsupported_grant_type |
Missing assertion; missing target (no resource or audience); both resource and audience; repeated resource or audience | invalid_request |
A client_assertion was supplied but failed to authenticate | invalid_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 lifetime | invalid_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.