Workload identity: Kubernetes
Every Kubernetes cluster can project a signed service-account token into a pod, with an audience you choose and a short lifetime. If the cluster’s issuer publishes its discovery document and keys over HTTPS (managed clusters on GKE, EKS and AKS do, and self-managed clusters can), Syntic can verify those tokens directly. Pods then exchange them for Syntic access tokens.
Claims you can match on
| Claim | Example | Use it to |
|---|---|---|
sub | system:serviceaccount:billing:summariser | Trust one Kubernetes service account in one namespace. |
iss | Your cluster’s issuer URL | Set as the rule’s issuer. |
aud | https://app.syntic.ai | Set as the rule’s audience. |
Use sub with the exact namespace and service account. A trailing *, for example system:serviceaccount:billing:*, trusts every service account in a namespace; use it only for namespaces you control tightly.
Set it up
Find the cluster’s issuer
kubectl get --raw /.well-known/openid-configuration | jq -r .issuerThe URL must be reachable from the internet over HTTPS, with its keys published at the jwks_uri in that document.
Create the Syntic service account and trust rule
{
"issuer": "https://oidc.k8s.acme.example.com",
"audience": "https://app.syntic.ai",
"conditions": {
"sub": "system:serviceaccount:billing:summariser"
},
"max_token_lifetime_seconds": 900
}Project a token into the pod
apiVersion: v1
kind: Pod
metadata:
name: summariser
namespace: billing
spec:
serviceAccountName: summariser
containers:
- name: app
image: ghcr.io/acme/summariser:1.4.0
volumeMounts:
- name: syntic-token
mountPath: /var/run/secrets/syntic
readOnly: true
volumes:
- name: syntic-token
projected:
sources:
- serviceAccountToken:
path: token
audience: https://app.syntic.ai
expirationSeconds: 3600The kubelet refreshes the file before it expires. Read it fresh before each exchange:
import { readFile } from 'node:fs/promises'
let cached = { token: null, until: 0 }
export async function synticToken() {
if (Date.now() < cached.until - 60_000) return cached.token
const jwt = (await readFile('/var/run/secrets/syntic/token', 'utf8')).trim()
const res = await fetch('https://app.syntic.ai/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'syntic-beta': 'token-exchange-2026-11-01' },
body: JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: jwt,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
service_account: 'svcacct_01JCB3D5F7H9K1M3P5R7',
}),
})
const body = await res.json()
if (!res.ok) throw new Error(`${body.error}: ${body.error_description}`)
cached = { token: body.access_token, until: Date.now() + body.expires_in * 1000 }
return cached.token
}Use a dedicated projected token with the Syntic audience, not the default service-account token mounted for the Kubernetes API. The default token has a different audience and is refused.