Service Account Token Flow
Overview
Traces how a pod obtains a short-lived, audience-scoped JWT that it uses to authenticate to the Kubernetes API server or external services (IRSA, Workload Identity).
Token Types
| Type | Mechanism | Lifetime | Audience | Rotation |
|---|---|---|---|---|
| Legacy SA secret token | Secret auto-mounted | Infinite | kubernetes.default.svc | Never |
| Projected volume token (BoundServiceAccountToken) | TokenRequest API | 1 hour (default) | configurable | Auto by kubelet |
| External OIDC (IRSA/Workload Identity) | Projected volume → federated | 1 hour | AWS/GCP STS | Auto by kubelet |
Legacy tokens are disabled in Kubernetes 1.24+ by default. All new workloads should use projected volume tokens.
Projected Volume Token Flow
API Server etcd kubelet Pod (container) AWS STS
│ │ │ │ │
│ │ │ │ │
│ [pod spec includes projected serviceAccountToken volume] │
│ │ │ │ │
│──WATCH event ───────────────► │ │ │
│ (pod scheduled to this node) │ │ │
│ │ │ │
│◄── TokenRequest ─────────────────│ │ │
│ POST /api/v1/namespaces/default/serviceaccounts/ │ │
│ payments-sa/token │ │
│ { │ │
│ "audiences": ["sts.amazonaws.com"], │ │
│ "expirationSeconds": 3600 │ │
│ } │ │
│ │ │
│ [API server signs JWT with cluster's private key] │ │
│ JWT payload: │ │
│ iss: https://oidc.eks.us-east-1.amazonaws.com/id/CLUSTER_ID │
│ sub: system:serviceaccount:production:payments-sa │ │
│ aud: sts.amazonaws.com │ │
│ exp: now + 3600 │ │
│ kubernetes.io/pod: {name, uid, namespace} │ │
│ │ │
│──► 200 {token: "eyJhbGci..."} ──────────────────────►│ │
│ │ │
│ kubelet writes token to projected volume: │ │
│ /var/run/secrets/kubernetes.io/serviceaccount/token │
│ (tmpfs mount in pod — not on disk) │ │
│ │ │
│ [kubelet schedules re-request 80% into TTL] │ │
│ [~48 min for 1h token] │ │
│ │ │
│ pod starts │
│ │ │
│ ┌──── pod calls AWS API ──────────►│ │
│ │ reads JWT from /var/run/... │ │
│ │ │ │
│ │ AssumeRoleWithWebIdentity ─────────────────────► │
│ │ { │
│ │ RoleArn: arn:aws:iam::123:role/payments-sa │
│ │ WebIdentityToken: <JWT> │
│ │ RoleSessionName: payments-sa │
│ │ } │
│ │ │
│ │ [STS verifies JWT signature against │
│ │ OIDC provider public keys from │
│ │ https://oidc.eks.../id/CLUSTER_ID/.well-known/ │
│ │ openid-configuration] │
│ │ │
│ │◄── {AccessKeyId, SecretAccessKey, SessionToken} ──│
│ │ (valid 1 hour) │
│ │ │
│ pod calls S3, DynamoDB, etc. with temporary credentials │
Projected Volume Pod Spec
apiVersion: v1
kind: Pod
metadata:
name: payments-api
namespace: production
spec:
serviceAccountName: payments-sa
automountServiceAccountToken: false # disable legacy auto-mount
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # kubelet refreshes at 80% of TTL
audience: sts.amazonaws.com # audience claim in JWT
- name: kube-api-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600
audience: https://kubernetes.default.svc # for calls to K8s API
containers:
- name: payments-api
image: ghcr.io/acme/payments-api:latest
volumeMounts:
- name: aws-token
mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
readOnly: true
- name: kube-api-token
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
readOnly: true
env:
- name: AWS_ROLE_ARN
value: arn:aws:iam::123456789012:role/payments-sa
- name: AWS_WEB_IDENTITY_TOKEN_FILE
value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
Token Rotation Mechanics
t=0 kubelet requests token (TTL=3600s)
t=2880s kubelet proactively requests new token (80% of 3600s)
→ writes new token atomically to projected volume file
→ pod reads new token on next file read (no restart needed)
t=3600s old token expires (but pod already using new token)
The file is replaced atomically (rename from temp file).
Applications must re-read the token file on each use, not cache it.
Go SDK — Correct Token Handling
// AWS SDK v2 reads AWS_WEB_IDENTITY_TOKEN_FILE automatically
// It re-reads the file on each credential refresh
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
)
// No token file handling needed — SDK does it
// For Kubernetes client-go — also re-reads token file automatically
clientConfig, err := rest.InClusterConfig()
// InClusterConfig reads /var/run/secrets/kubernetes.io/serviceaccount/token
// It refreshes the token transparently
IRSA IAM Role Trust Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/CLUSTER_ID"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/CLUSTER_ID:sub":
"system:serviceaccount:production:payments-sa",
"oidc.eks.us-east-1.amazonaws.com/id/CLUSTER_ID:aud":
"sts.amazonaws.com"
}
}
}
]
}
Diagnosing Token Issues
# Verify projected token exists inside pod
kubectl exec -n production deploy/payments-api -- \
cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token | \
python3 -c "import sys,json,base64; parts=sys.stdin.read().split('.'); \
payload=parts[1]+'=='*(-len(parts[1])%4); \
print(json.dumps(json.loads(base64.b64decode(payload)),indent=2))"
# Expected output includes:
# "iss": "https://oidc.eks.us-east-1.amazonaws.com/id/...",
# "sub": "system:serviceaccount:production:payments-sa",
# "aud": ["sts.amazonaws.com"],
# "exp": <future unix timestamp>
# Check IRSA annotation on ServiceAccount
kubectl get sa payments-sa -n production \
-o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}'
# Manually test AssumeRoleWithWebIdentity
TOKEN=$(kubectl exec -n production deploy/payments-api -- \
cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token)
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/payments-sa \
--role-session-name test \
--web-identity-token "$TOKEN"
Related
- 06 — Service Accounts — RBAC for service accounts
- 04 — Security Hardening — automountServiceAccountToken:false
- 09 — RBAC Flow — how the JWT is used in API server authorization