The usual way to give a pod access to AWS is an access key in a Kubernetes secret. It never expires, it ends up in backups and CI logs, and rotating it is a chore you postpone. On EKS you would use IRSA instead: the pod proves who it is with its service-account token and gets short-lived credentials.
That is not an EKS feature. It is plain OIDC, and a single k3s node can do it too. My k3s server now sends mail through Amazon SES this way, and there is no AWS key anywhere on it. It took a handful of Pulumi resources, one k3s config file and no admission webhook. It also took three gotchas, which are the actual reason for this post.
How it works
- Kubernetes mounts a service-account token into the pod, signed by the API
server, with the audience
sts.amazonaws.com. - The AWS SDK in the pod sends that token to STS
(
AssumeRoleWithWebIdentity) together with a role ARN. - STS looks up the token’s issuer, fetches the issuer’s public keys over HTTPS and checks the signature.
- If the role trusts exactly this service account, STS returns credentials valid for an hour. The SDK refreshes them on its own.
So AWS needs three things: a public HTTPS address for your issuer, the issuer registered as an IAM OIDC provider, and a role that trusts one specific service account.
1. Put the issuer documents in S3, not in the cluster
STS fetches two small JSON files from the issuer URL:
/.well-known/openid-configuration: who the issuer is, where the keys are/openid/v1/jwks: the public half of the key that signs the tokens
Neither is secret. k3s serves both, but only inside the cluster, under the
issuer https://kubernetes.default.svc.cluster.local, which AWS cannot
reach.
You could expose them through your ingress. I did not: then every AWS call from every pod depends on your own ingress being up and reachable from AWS. A public S3 bucket is more boring and more reliable. The issuer becomes the bucket URL, and the bucket policy makes exactly two objects readable:
discovery = {
"issuer": ISSUER_URL, # https://<bucket>.s3.<region>.amazonaws.com
"jwks_uri": f"{ISSUER_URL}/openid/v1/jwks",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
}
The JWKS file is a copy of what the cluster publishes:
kubectl get --raw /openid/v1/jwks > jwks.json
Then register the issuer with IAM. No thumbprint needed:
provider = aws.iam.OpenIdConnectProvider(
"k3s",
url=ISSUER_URL,
client_id_lists=["sts.amazonaws.com"],
opts=pulumi.ResourceOptions(depends_on=[policy, discovery_doc, jwks_doc]),
)
Gotcha 1: that depends_on is not decoration. IAM fetches the discovery
document at creation time. Pulumi created the provider in parallel with
the bucket objects, and the first run failed with:
InvalidInput: Please check .well-known/openid-configuration of provider:
https://<bucket>.s3.eu-central-1.amazonaws.com is valid.
The documents have to be public before the provider exists.
2. Tell k3s to sign with the new issuer
The API server has to put the S3 URL into the iss claim of new tokens.
k3s takes API server flags from a config file, so this is a drop-in:
# /etc/rancher/k3s/config.yaml.d/oidc.yaml
kube-apiserver-arg+:
- service-account-issuer=https://<bucket>.s3.eu-central-1.amazonaws.com
- service-account-issuer=https://kubernetes.default.svc.cluster.local
- service-account-jwks-uri=https://<bucket>.s3.eu-central-1.amazonaws.com/openid/v1/jwks
Two issuers is the trick that makes this safe on a running cluster. The first one signs new tokens; every one in the list is still accepted. Tokens already mounted in pods carry the old issuer and keep working, and the kubelet replaces them with new ones on its normal refresh. The switch costs one k3s restart: a few seconds without an API server, running pods are not touched.
Check it:
kubectl create token default --audience sts.amazonaws.com \
| cut -d. -f2 | base64 -d 2>/dev/null | jq -r .iss
# https://<bucket>.s3.eu-central-1.amazonaws.com
Does this change every token in the cluster? The issuer, yes: every new
token now names the S3 URL. The audience, no. A normal pod still gets a
token for https://kubernetes.default.svc.cluster.local and k3s; only
tokens requested with audience: sts.amazonaws.com (step 4) carry the AWS
audience. STS rejects everything else, so a leaked token from some other
pod is worthless at AWS.
Gotcha 2: note the + in kube-apiserver-arg+. In
k3s config files, “the
last value found for a given key will be used”. A drop-in with plain
kube-apiserver-arg: silently replaces any API server flags you already set
in config.yaml. The + appends. Mine used the plain key at first. It
only worked because config.yaml set no API server flags; it uses + now.
Gotcha 3: if a tool manages that file, check what happens when the
content changes. I write it with a Pulumi remote.Command whose delete
removes the file. A content change replaces the command, and Pulumi’s
default is create before delete: write the new file, restart, then run the
old resource’s delete, which removes the same path and restarts again.
End state: no drop-in, tokens signed with the cluster-internal issuer again,
STS rejects all of them, and every AWS call fails. One option fixes it:
opts=pulumi.ResourceOptions(delete_before_replace=True)
On a single node, also keep a way back that does not need the API server. Mine is a script that removes the drop-in and restarts k3s over SSH.
3. A role that trusts one service account
"Principal": {"Federated": oidc_provider_arn},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {"StringEquals": {
f"{issuer_host}:sub": "system:serviceaccount:my-app:my-app",
f"{issuer_host}:aud": "sts.amazonaws.com",
}},
Pin both sub and aud, with StringEquals. Without the sub condition,
every service account in the cluster can assume the role. A StringLike
with a wildcard is nearly as bad: any pod in that namespace qualifies.
4. The pod: no webhook needed
On EKS, a mutating webhook injects the token volume and environment variables. On k3s you can just write them into the pod spec. It is three lines of environment and one projected volume:
spec:
serviceAccountName: my-app
automountServiceAccountToken: false
containers:
- name: app
env:
- { name: AWS_REGION, value: eu-central-1 }
- { name: AWS_ROLE_ARN, value: arn:aws:iam::<account>:role/my-app-ses }
- {
name: AWS_WEB_IDENTITY_TOKEN_FILE,
value: /var/run/secrets/aws/token,
}
volumeMounts:
- { name: aws-token, mountPath: /var/run/secrets/aws, readOnly: true }
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken:
audience: sts.amazonaws.com
expirationSeconds: 3600
path: token
Every official AWS SDK picks up AWS_ROLE_ARN plus
AWS_WEB_IDENTITY_TOKEN_FILE in its default credential chain. The
application code does not change. automountServiceAccountToken: false
keeps the regular Kubernetes API token out of the pod; it only gets the one
with the AWS audience.
In my case the consumer is ses-relay, a small sidecar that takes SMTP from Listmonk and sends it through the SES API. It has never seen an SMTP password or an access key.
What can still break
The signing key. The JWKS in S3 is a copy. If the cluster’s
service-account key changes, for example after rebuilding the server, STS
rejects every new token until you upload the new JWKS. Pods do not notice
right away: the kubelet refreshes a one-hour token at 80% of its lifetime,
and the AWS credentials from the last exchange stay valid for up to an hour.
That is your window. A check that compares kubectl get --raw /openid/v1/jwks with the copy in S3 and alerts on a difference closes it.
Cost. STS reads the two documents when it validates a token. S3 charges about $0.0004 per 1,000 GET requests in eu-central-1, and both files together are under 1 KB. Even if STS fetched them on every exchange, one pod refreshing hourly is around 730 requests a month: a fraction of a cent.
The bucket is public, so anyone can request those files too, and successful requests are billed to you. That is the realistic abuse case: one million requests cost about $0.43, plus about 1 GB of transfer that the free tier covers. Requests for anything else in the bucket are denied, and S3 no longer bills you for unauthorized requests from outside your account. A budget alert in AWS Billing is enough to notice if someone tries.
What an attacker would need
Everything in this post is public by design: the issuer URL, the public key, the role’s trust policy. Knowing it does not help, because a token is only accepted if it is signed with the cluster’s private key. That key never leaves the node.
So the questions are who could get a token signed by a key AWS trusts:
- Root on the node, or anyone who can read
/var/lib/rancher/k3s/server/tls/service.key. They can mint tokens for any service account. Protect the node; this is the same key your whole cluster trusts anyway. - Anyone who can create pods in the trusted namespace with the trusted
service account. That is a Kubernetes RBAC question: the role trusts
my-app:my-app, so whoever can run pods as that service account gets the role. - Anyone who can write to the bucket. Replacing the JWKS with their own
key would make AWS accept their tokens. The bucket policy only allows
s3:GetObjecton the two files; writes need credentials for your AWS account. - Anyone who gets the bucket name after you lose it. S3 bucket names are
global. If you delete the bucket while the IAM OIDC provider still trusts
its URL, someone else can create a bucket with the same name, publish
their own key and assume your roles. Never delete the bucket first. I
mark both with
protect=Truein Pulumi, so not evenpulumi destroyremoves them by accident; to tear it down, delete the OIDC provider, then the bucket.
What you get. No AWS key on the server, in the cluster, in CI or in a backup. The role can only be assumed by one service account in one namespace, and the credentials are useless an hour later.