tokenex
A Go library that exchanges the identity token your workload already has for short-lived credentials, and keeps them fresh. No keys to store or rotate.
A long-lived key works for anyone who has a copy. It sits in a config file, an environment variable or a CI secret, and someone has to rotate it.
Cloud providers, secret stores and now Anthropic and OpenAI offer workload identity federation instead. The workload proves who it is with a signed token and gets short-lived credentials back. Most workloads already have such a token: SPIRE issues JWT-SVIDs, GitHub Actions issues OIDC tokens, and Kubernetes signs one for every service account. tokenex does the exchange, refreshes the credentials before they expire, and hands each new set to your code on a Go channel.
How it works
Section titled “How it works”This is the quick start below: a workload exchanges its SPIRE JWT-SVID for AWS credentials.
tokenex’s parts have amber outlines. Swap either one for another identity token source or credentials provider, and the rest stays the same.
-
The SPIRE agent issues the workload a JWT-SVID through the Workload API. The identity token provider asks for one each time it’s called, so it never hands out a stale token.
-
The AWS credentials provider gets a token from it, with the audience
sts.amazonaws.com. -
It exchanges the token at AWS STS with
AssumeRoleWithWebIdentity, for the role you named. -
STS checks the token’s signature against the keys SPIRE’s OIDC Discovery Provider publishes, which an IAM OIDC identity provider trusts.
-
The temporary credentials arrive on the channel as an
Update. Before they expire, tokenex goes back to step 2 and sends the new ones on the same channel. -
Your code signs its AWS calls with them.
Quick start
Section titled “Quick start”go get go.riptides.io/tokenexExchange the workload’s SPIRE JWT-SVID for AWS credentials and keep them fresh:
package main
import ( "context" "log"
awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/go-logr/logr"
"go.riptides.io/tokenex/pkg/aws" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/token/spiffe")
func main() { ctx := context.Background()
// JWT-SVIDs from the SPIRE agent at SPIFFE_ENDPOINT_SOCKET. svids, err := spiffe.NewIdentityTokenProvider(spiffe.WithAudience("sts.amazonaws.com")) if err != nil { log.Fatal(err) }
provider, err := aws.NewCredentialsProvider(ctx, logr.Discard(), &awssdk.Config{Region: "us-east-1"}) if err != nil { log.Fatal(err) }
creds, err := provider.GetCredentials(ctx, svids, aws.WithRoleArn("arn:aws:iam::123456789012:role/spire-demo"), aws.WithRoleSessionName("spire-demo"), ) if err != nil { log.Fatal(err) }
// A fresh result arrives before the previous credentials expire. for res := range creds { if res.Err != nil { log.Fatal(res.Err) } c := res.Credential.(*credential.AWSCreds) log.Printf("access key %s, expires %s", c.AccessKeyID, c.Expires) }}AWS needs an IAM OIDC identity provider that trusts SPIRE’s OIDC Discovery Provider; examples/spire-aws has the setup steps and a runnable version. In CI, swap spiffe for githubactions. For another target, swap aws for any of the providers.
What you get
Section titled “What you get”No keys to store
The workload proves who it is with a token it already has. There’s nothing long-lived to put in a config file or a CI secret, and nothing to rotate.
Always fresh
Each provider refreshes its credentials before they expire and sends the new ones on the same channel, with a new identity token for every exchange.
Mix and match
Any identity token source works with any provider that exchanges one: GitHub Actions to Azure, SPIRE to Vault, a static JWT to OpenAI.
AI providers too
Anthropic and OpenAI workload identity federation, alongside AWS, GCP, Azure, OCI, Vault and CyberArk Conjur.
Traced
Credentials providers record OpenTelemetry spans for their exchanges, with the tracer provider you pass or the one in the context.
Open source
MIT. Waterwheel and the Riptides platform use it.
Providers
Section titled “Providers”Each provider has its own page with a full example.
Identity token sources
Section titled “Identity token sources”- Static
pkg/tokenHolds a JWT you already have; SetToken replaces it.
- GitHub Actions
pkg/token/githubactionsOIDC ID tokens from the GitHub Actions runtime.
- SPIFFE
pkg/token/spiffeJWT-SVIDs from the SPIFFE Workload API, e.g. a SPIRE agent.
To bring your own source, implement token.IdentityTokenProvider or wrap a function with token.IdentityTokenProviderFunc.
Exchanged for
Section titled “Exchanged for”Any identity token source above works with each of these.
- AWS
pkg/awsTemporary session credentials, through AWS's workload identity federation.
- GCP
pkg/gcpAccess tokens, through GCP's Workload Identity Federation.
- Azure
pkg/azureAccess tokens, through Microsoft Entra ID's workload identity federation.
- OCI
pkg/ociUser principal session tokens (UPST), through OCI's workload identity federation.
- Vault / OpenBao
pkg/vaultSecrets from Vault or OpenBao, with JWT authentication.
- CyberArk Conjur
pkg/conjurSecrets from Conjur's JWT authenticator, polled for rotated values.
- RFC 7523
pkg/rfc7523OAuth2 access tokens with the jwt-bearer grant, including Anthropic workload identity federation.
- RFC 8693
pkg/rfc8693OAuth2 access tokens with token exchange, including OpenAI workload identity federation.
- Generic
pkg/genericThe identity token itself, refreshed before it expires.
Other refreshed credentials
Section titled “Other refreshed credentials”- Kubernetes Secret
pkg/k8ssecretWatches a Secret that holds a token, and sends each change.
- OAuth2 authorization code
pkg/oauth2acAccess tokens from the authorization code flow, refreshed before they expire.
- OAuth2 client credentials
pkg/oauth2ccAccess tokens from the client credentials flow, refreshed before they expire.
- GitHub App
pkg/githubappInstallation access tokens, minted with the App's private key.
The credential channel
Section titled “The credential channel”GetCredentials returns a <-chan credential.Result, and every provider uses it the same way:
- The first credential and each refresh arrive as an
Updateevent. - If the credential is removed, a
Removeevent arrives. - On an error,
Erris set,Credentialis nil and the refresh loop stops. Retrying is up to you. - When the loop stops, the channel is closed.
The channel keeps delivering for as long as the context lives, so read it in its own goroutine. To shut down, cancel the context and wait for the goroutine.
var wg sync.WaitGroupwg.Go(func() { for res := range creds { if res.Err != nil { log.Printf("credentials: %v", res.Err) // the loop has stopped return } switch res.Event { case credential.UpdateEventType: // New or refreshed: use res.Credential from now on. case credential.RemoveEventType: // The credential is gone: stop using it. } }})
// On shutdown.cancel()wg.Wait()Read more
Section titled “Read more”tokenex is built by Riptides.