Kubernetes has its own identity system for workloads, and it's more powerful than most teams realize. Rather than distributing long-lived secrets to pods, you can use the cluster's built-in OIDC capabilities to issue short-lived, audience-scoped tokens that federate with cloud provider IAM systems. No static credentials in environment variables. No secrets rotation burden. This post covers how the system works and how to use it.
ServiceAccounts: the primitives
Every pod in Kubernetes runs as a ServiceAccount. If you don't specify one, it runs as the default ServiceAccount in its namespace — which is an anti-pattern because it makes every pod indistinguishable from an auth perspective.
# Always create dedicated ServiceAccounts per workload
apiVersion: v1
kind: ServiceAccount
metadata:
name: auth-service
namespace: production
annotations:
# For AWS: bind to an IAM role (IRSA)
eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/auth-service-role
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-service
spec:
template:
spec:
serviceAccountName: auth-service # bind the pod to the ServiceAccount
automountServiceAccountToken: true # mount the projected token
Projected volume tokens
Kubernetes 1.20+ mounts ServiceAccount tokens as projected volumes rather than legacy secrets. Projected tokens are:
- Short-lived — default 1-hour expiry, auto-rotated by the kubelet
- Audience-scoped — bound to a specific audience (prevents token reuse across services)
- Pod-bound — contain the pod UID and will be rejected if the pod is deleted
spec:
volumes:
- name: token
projected:
sources:
- serviceAccountToken:
audience: "https://auth-service.internal"
expirationSeconds: 3600 # 1 hour
path: token
containers:
- name: app
volumeMounts:
- name: token
mountPath: /var/run/secrets/tokens
readOnly: true
Reading the token from inside the container:
import { readFileSync } from 'fs';
function getServiceAccountToken(): string {
// Default mount path for auto-mounted SA token
return readFileSync(
'/var/run/secrets/kubernetes.io/serviceaccount/token',
'utf8'
).trim();
}
// Or from a custom projected volume path:
function getAudienceScopedToken(audience: string): string {
return readFileSync(`/var/run/secrets/tokens/${audience}`, 'utf8').trim();
}
The OIDC provider: how the cluster issues tokens
Kubernetes clusters expose an OIDC discovery endpoint. Cloud providers (AWS, GCP, Azure) and other systems can use this to verify tokens issued by the cluster without calling the Kubernetes API:
# Get your cluster's OIDC issuer URL (EKS example)
aws eks describe-cluster \
--name my-cluster \
--query "cluster.identity.oidc.issuer" \
--output text
# Output: https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE
# The OIDC discovery document is at {issuer}/.well-known/openid-configuration
curl https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE/.well-known/openid-configuration
A decoded ServiceAccount JWT looks like:
{
"iss": "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE",
"sub": "system:serviceaccount:production:auth-service",
"aud": ["https://auth-service.internal"],
"exp": 1707562800,
"iat": 1707559200,
"kubernetes.io": {
"namespace": "production",
"pod": {
"name": "auth-service-7d9f4b-abc12",
"uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
},
"serviceaccount": {
"name": "auth-service",
"uid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
}
}
}
IRSA: federating with AWS IAM (EKS)
IAM Roles for Service Accounts (IRSA) lets a pod's ServiceAccount token be exchanged for AWS STS credentials — without storing AWS access keys anywhere.
# Create an IAM OIDC identity provider for your cluster
eksctl utils associate-iam-oidc-provider \
--cluster my-cluster \
--approve
# Create an IAM role with a trust policy that allows the ServiceAccount
# Trust policy:
cat <<EOF > trust-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE:sub":
"system:serviceaccount:production:auth-service",
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE:aud":
"sts.amazonaws.com"
}
}
}]
}
EOF
aws iam create-role \
--role-name auth-service-role \
--assume-role-policy-document file://trust-policy.json
The AWS SDK automatically reads the projected token and exchanges it for credentials via STS:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
// No credentials needed in code — the SDK reads from:
// AWS_ROLE_ARN: arn:aws:iam::123456789:role/auth-service-role
// AWS_WEB_IDENTITY_TOKEN_FILE: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
const secretsClient = new SecretsManagerClient({ region: 'us-east-1' });
async function getDatabasePassword(): Promise<string> {
const result = await secretsClient.send(new GetSecretValueCommand({
SecretId: '/production/auth-service/db-password',
}));
return result.SecretString!;
}
Verifying SA tokens in your own services
If you want service A to authenticate to service B using Kubernetes SA tokens (rather than mTLS), service B needs to verify the token against the cluster's JWKS endpoint:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const CLUSTER_OIDC_ISSUER = process.env.CLUSTER_OIDC_ISSUER!;
const JWKS = createRemoteJWKSet(new URL(`${CLUSTER_OIDC_ISSUER}/openid/v1/jwks`));
async function verifyServiceAccountToken(token: string): Promise<{
namespace: string;
serviceAccount: string;
} | null> {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: CLUSTER_OIDC_ISSUER,
audience: 'https://auth-service.internal',
});
const sub = payload.sub as string;
// sub format: "system:serviceaccount:NAMESPACE:NAME"
const [, , namespace, serviceAccount] = sub.split(':');
return { namespace, serviceAccount };
} catch {
return null;
}
}
createRemoteJWKSet handles caching internally, but consider a Redis-backed cache layer if you're validating thousands of tokens per second.