Workload identity: Kubernetes

⚠️
Preview — this feature is being built; the design below is the spec.

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

ClaimExampleUse it to
subsystem:serviceaccount:billing:summariserTrust one Kubernetes service account in one namespace.
issYour cluster’s issuer URLSet as the rule’s issuer.
audhttps://app.syntic.aiSet 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 .issuer

The 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: 3600

The 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.

Was this page helpful?