OIDC token exchange
The platform supports RFC 8693 token exchange. You can exchange an external OIDC token for a platform access key. Automation tools and CI/CD pipelines can use this flow to authenticate without storing long-lived secrets.
How it works​
An external identity provider issues a short-lived OIDC token to a workload. The workload sends the token to the platform. The platform verifies the token and returns an access key. The workload uses the access key to log in with vcluster platform login, or directly in API requests.
The access key has the same permissions as the platform user that the token identity maps to.
The exchange follows these steps:
- The pipeline requests an OIDC token from its runtime, such as GitHub Actions or Azure DevOps.
- The pipeline sends the token to the platform token exchange endpoint.
- The platform verifies the token against the configured SSO connector and issues an access key.
- The pipeline uses the access key to log in.
Token exchange endpoint​
POST https://<platform-host>/auth/<connector-id>/token
Content-Type: application/x-www-form-urlencoded
Parameters:
| Parameter | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | The OIDC token from the external provider |
subject_token_type | urn:ietf:params:oauth:token-type:id_token or urn:ietf:params:oauth:token-type:access_token |
The <connector-id> in the URL must match the id field of the connector in your platform configuration. Use oidc if you authenticate with the primary OIDC provider configured under auth.oidc.
Response:
{
"access_token": "<platform-access-key>",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 300
}
expires_in is the lifetime of the access key in seconds. The platform caps the key to the remaining lifetime of the subject token. The value is the shorter of the platform login TTL and the time left on the subject token. A subject token that expires in approximately 5 minutes returns an expires_in value no greater than 300. Don't cache the access key for longer than expires_in seconds.
The access key expires at a fixed time. It has no inactivity extension and no identity refresh, so a pipeline that needs a longer session must exchange a new token.
Token types​
The platform accepts two token types.
ID tokens (urn:ietf:params:oauth:token-type:id_token): The platform verifies the token against the provider JWKS endpoint. The aud claim must match the clientId value in the connector configuration. Use this type for GitHub Actions.
Access tokens (urn:ietf:params:oauth:token-type:access_token): The platform verifies the token against the provider JWKS endpoint. The aud claim must match the resource value in the connector configuration. Set the resource field in the platform configuration before you exchange access tokens. Use this type for providers such as Microsoft Entra ID.
Configure a connector for token exchange​
Token exchange works with any OIDC SSO connector. Add the connector under auth.connectors. For the full list of supported providers, see SSO providers.
auth:
connectors:
# The id becomes part of the exchange URL: /auth/my-provider/token
- id: my-provider
oidc:
issuerUrl: https://my-oidc-provider.example.com
# Must match the aud claim in the tokens that the provider issues
clientId: <expected-audience>
# Leave empty if the provider doesn't require a client secret
clientSecret: ""
# Claim that identifies the platform user
usernameClaim: sub
# Namespace the subject to prevent collisions with other connectors
usernamePrefix: "my-provider:"
loftUsernameClaim: sub
groupsClaim: ""
If your platform uses auth.oidc as the primary SSO provider, token exchange is available at /auth/oidc/token.
GitHub Actions​
GitHub Actions issues OIDC tokens for workflows that have the id-token: write permission. The issuer URL is the same for all GitHub organizations.
auth:
connectors:
- id: github-actions
oidc:
# Global GitHub Actions OIDC issuer
issuerUrl: https://token.actions.githubusercontent.com
# Must match the audience that the workflow requests
clientId: https://<platform-host>
clientSecret: ""
# repository_id is a stable numeric ID; the platform matches returning
# users against this claim, so use it instead of the repository name
usernameClaim: repository_id
# Namespace the subject to prevent collisions with other connectors
usernamePrefix: "github-actions:"
# Use the repository name for the initial platform username and display name
loftUsernameClaim: repository
groupsClaim: ""
- The
clientIdmust match theaudienceparameter that the workflow uses when it requests the token. usernameClaimis the claim the platform uses to match a returning identity to an existing user, so use the stablerepository_idclaim rather thanrepository. A renamed repository keeps the samerepository_id. A repository name can be reused after deletion, though, which would otherwise let a new repository inherit the old one's platform identity and permissions. TheusernamePrefixnamespaces the numeric ID to prevent collisions with subjects from other SSO connectors.loftUsernameClaimprovides the initial platform username and display name. The human-readablerepositoryclaim (<owner>/<repo>) works well here, while returning-user matching continues to useusernameClaim. For all claims GitHub Actions includes in OIDC tokens, see the GitHub Actions OIDC reference.- This configuration maps every workflow and Git ref in one repository to the same platform user. Use a more specific stable claim if separate workflows need different platform permissions.
Workflow example:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Get OIDC token
id: oidc
run: |
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://$PLATFORM_HOST")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
OIDC_TOKEN=$(echo "$BODY" | jq -r '.value // empty')
if [ "$HTTP_CODE" != "200" ] || [ -z "$OIDC_TOKEN" ]; then
echo "OIDC token request failed with HTTP $HTTP_CODE"
exit 1
fi
echo "::add-mask::$OIDC_TOKEN"
echo "token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT"
env:
PLATFORM_HOST: <platform-host>
- name: Exchange token
id: exchange
run: |
RESPONSE=$(curl -sS -X POST \
"https://$PLATFORM_HOST/auth/github-actions/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=${{ steps.oidc.outputs.token }}" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:id_token")
KEY=$(echo "$RESPONSE" | jq -r '.access_token // empty')
if [ -z "$KEY" ]; then
echo "Token exchange failed: $RESPONSE"
exit 1
fi
echo "::add-mask::$KEY"
echo "key=$KEY" >> "$GITHUB_OUTPUT"
env:
PLATFORM_HOST: <platform-host>
- name: Install the vCluster CLI
uses: loft-sh/setup-vcluster@main
- name: Log in to the platform
run: |
vcluster platform login "https://$PLATFORM_HOST" \
--access-key "${{ steps.exchange.outputs.key }}"
env:
PLATFORM_HOST: <platform-host>
The ::add-mask:: command keeps the access key out of the workflow logs.
Azure DevOps​
Azure DevOps issues OIDC tokens through a Generic service connection that uses workload identity federation. Unlike GitHub Actions, the audience is fixed, and the trust boundary is the issuer URL instead.
auth:
connectors:
- id: azure-devops
oidc:
# Includes your organization GUID; this is the actual trust boundary
issuerUrl: https://vstoken.dev.azure.com/<org-guid>
# Fixed audience: Azure DevOps doesn't let you customize it per pipeline
clientId: api://AzureADTokenExchange
clientSecret: ""
# Azure DevOps tokens have no email claim
usernameClaim: sub
# Namespace the subject to prevent collisions with other connectors
usernamePrefix: "azure-devops:"
loftUsernameClaim: sub
groupsClaim: ""
- The
clientIdmust beapi://AzureADTokenExchange. Azure DevOps shares this audience across every organization, so theissuerUrl, which includes your organization GUID, is what actually scopes the trust to your organization. - Azure DevOps tokens have no
emailclaim. SetusernameClaimandloftUsernameClaimtosub, which follows the formatsc://<org>/<project>/<service-connection-name>. - The
usernamePrefixprevents the subject from matching a user created through another SSO connector. - The pipeline needs a Generic service connection, even though the token request doesn't reference it as a resource. Assign the service connection to at least one task in the pipeline so Azure DevOps authorizes the request. Any built-in task with a
connectedServiceinput works, such asFtpUpload@2in the example below.
Pipeline example:
steps:
# A task must reference the service connection so Azure DevOps
# authorizes this pipeline to request an OIDC token for it.
- task: FtpUpload@2
displayName: "Authorize service connection for OIDC"
continueOnError: true
timeoutInMinutes: 1
inputs:
serverEndpoint: <service-connection-name>
rootDirectory: $(System.DefaultWorkingDirectory)
filePatterns: "nonexistent"
remoteDirectory: /
- script: |
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
-H "Content-Type: application/json" -H "Content-Length: 0" \
"$SYSTEM_OIDCREQUESTURI?api-version=7.1-preview.1&serviceConnectionId=<service-connection-id>")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
OIDC_TOKEN=$(echo "$BODY" | jq -r '.oidcToken // empty')
if [ "$HTTP_CODE" != "200" ] || [ -z "$OIDC_TOKEN" ]; then
echo "OIDC token request failed with HTTP $HTTP_CODE"
exit 1
fi
echo "##vso[task.setvariable variable=OIDC_TOKEN;issecret=true]$OIDC_TOKEN"
displayName: "Get OIDC token"
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
- script: |
RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST \
"https://<platform-host>/auth/azure-devops/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$OIDC_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:id_token")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
PLATFORM_TOKEN=$(echo "$BODY" | jq -r '.access_token // empty')
if [ "$HTTP_CODE" != "200" ] || [ -z "$PLATFORM_TOKEN" ]; then
echo "Token exchange failed with HTTP $HTTP_CODE: $BODY"
exit 1
fi
echo "##vso[task.setvariable variable=PLATFORM_TOKEN;issecret=true]$PLATFORM_TOKEN"
displayName: "Exchange token"
env:
OIDC_TOKEN: $(OIDC_TOKEN)
- script: |
VERSION=$(curl -s https://api.github.com/repos/loft-sh/vcluster/releases/latest | jq -r '.tag_name')
curl -L -o vcluster "https://github.com/loft-sh/vcluster/releases/download/${VERSION}/vcluster-linux-amd64"
chmod +x vcluster
sudo mv vcluster /usr/local/bin/
displayName: "Install the vCluster CLI"
- script: |
vcluster platform login "https://<platform-host>" --access-key "$PLATFORM_TOKEN"
displayName: "Log in to the platform"
env:
PLATFORM_TOKEN: $(PLATFORM_TOKEN)
Azure DevOps doesn't expose pipeline variables to a script automatically. Map each one explicitly through env, including System.AccessToken and any variable a previous step set with setvariable, such as OIDC_TOKEN and PLATFORM_TOKEN. Setting issecret=true on both bearer credentials keeps them out of the pipeline logs, the same way ::add-mask:: does for GitHub Actions.
The service connection must have Grant access permission to all pipelines enabled, or be explicitly authorized for this pipeline, or the OIDC token request fails.
Active Directory Federation Services (ADFS)​
ADFS doesn't follow the OIDC standard for access tokens. It publishes an access_token_issuer field in its discovery document, and it signs access tokens with that value as the iss claim. ADFS doesn't use the standard issuer value in access tokens.
Enable ADFS access token support
Set this environment variable on the platform deployment:
LOFT_OIDC_TRUST_ACCESS_TOKEN_ISSUER=true
When this variable is set, the platform reads the access_token_issuer field from the connector discovery document. If the field is present, the platform accepts only that value as the issuer for access tokens. The platform no longer accepts the standard issuer for access tokens. ID token validation doesn't change.
This variable applies to all connectors. Every connector with an access_token_issuer field in its discovery document adopts the replace behavior.
Troubleshoot token exchange errors​
Token verification failures return a generic error code and description. Check the platform log for the specific cause.
| Error | Cause | Fix |
|---|---|---|
invalid_grant: token verification failed | The issuerUrl or audience doesn't match, the resource field is missing or wrong, or the connector's discovery document doesn't trust the issuer | Check the platform log for the specific connector error. Decode the token (echo "$TOKEN" | jq -R 'split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson | {iss, aud}') and compare the values with the platform configuration. |
invalid_grant: access token has expired | The subject token's remaining lifetime reached zero before the platform issued an access key | Get a new token and retry immediately. |
invalid_grant: too many failed token exchange attempts (HTTP 429) | Five token exchanges from the same client IP failed within 10 minutes | Correct the token or connector configuration, wait 10 minutes, and retry. |
HTTP 404 on /auth/<connector-id>/token | The connector ID doesn't match, or the connector is disabled | Check that the connector id in the platform configuration matches the URL, and that the authentication method is enabled. |
Platform log messages​
These appear only in the platform log, not in the response the client receives.
| Log message | Cause | Fix |
|---|---|---|
access token authentication requires the resource (audience) to be configured | The resource field is missing from the platform configuration | Add resource: "<expected-audience>" to auth.oidc or the connector configuration. |
oidc: expected audience "api://..." got [...] | The resource field uses the api:// prefix, but the token contains the bare GUID | Set resource to the bare GUID. Don't use the api:// prefix. |
oidc: access token issued by a different provider | ADFS signs access tokens with access_token_issuer as iss instead of the standard issuer | Set LOFT_OIDC_TRUST_ACCESS_TOKEN_ISSUER=true on the platform deployment. |