Auth in a Kubernetes cluster: service accounts, OIDC federation, and workload identity

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;
  }
}
Cache the JWKS response — it changes infrequently and the cluster's OIDC endpoint isn't designed for high-throughput request verification. jose's createRemoteJWKSet handles caching internally, but consider a Redis-backed cache layer if you're validating thousands of tokens per second.