Kubernetes service account tokens: projection, rotatie en lifecycle

Na een upgrade voorbij Kubernetes 1.24 is het service account Secret waar je een token uit haalde leeg. Of het bestaat helemaal niet meer. Of kubectl create token geeft een string terug die een uur later niet meer werkt. Het tokenmodel is veranderd, en die verandering was een bewuste keuze. Dit artikel legt uit hoe projected service account tokens werken, waarom het cluster geen langlevende tokens meer uitdeelt, en wat dat betekent voor pods, CI/CD-pipelines en externe tooling die tegen de API-server authenticeren.

Waar dit artikel over gaat

Lees dit als je voorbij Kubernetes 1.24 bent geupgrade en hebt ontdekt dat pods, CI/CD-jobs of externe integraties die voorheen een token uit een Secret lazen, nu niets meer hebben om te lezen. Het mechanisme dat die tokens produceerde, is al meerdere releases weg, en de vervanger gedraagt zich anders op manieren die ertoe doen voor hoe je authenticatie inricht.

Dit is een conceptartikel. Het legt uit wat projected tokens zijn, waarom het model veranderd is, en hoe de onderdelen samenwerken. Voor de bijbehorende handelingen (een token aanmaken voor externe tooling, automountServiceAccountToken configureren, een pipeline tegen de API draaien) verwijst het artikel onderaan naar de relevante how-to- en referentiedocumentatie.

Wat service account tokens zijn en waarom pods ze nodig hebben

Een service account is de identiteit waarmee een pod authenticeert tegen de Kubernetes API-server. De pod logt niet in met een gebruikersnaam en wachtwoord. Hij presenteert een getekende JSON Web Token die de API-server valideert tegen sleutels die hij al vertrouwt.

Elke pod draait als precies een service account. Geef je in de pod-spec geen account op, dan draait de pod als default in zijn namespace. Dat default-account heeft uit zichzelf geen bruikbare permissies, maar het tokenmechanisme geldt nog steeds: er wordt een token gegenereerd, in de pod gemount en op schema geroteerd.

Tokens zijn ook nuttig buiten pods. CI/CD-pipelines, dashboards, monitoring-agents en losse kubectl-aanroepen authenticeren zich als service account wanneer ze geen user-identiteit kunnen gebruiken. Wat de aanroeper ook is, de check van de API-server is dezelfde: is het token geldig, en welk subject vertegenwoordigt het? RBAC bepaalt vervolgens wat dat subject mag doen, en dat is het onderwerp van het RBAC-conceptartikel.

Voor Kubernetes 1.24: Secret-tokens (en waarom ze zijn afgeschaft)

In Kubernetes 1.23 en eerder kreeg elk service account automatisch een gegenereerd Secret van het type kubernetes.io/service-account-token. De tokens-controller maakte het aan zodra je kubectl create serviceaccount draaide. Dat Secret bevatte een token dat:

  • Niet verliep
  • Niet was gebonden aan een pod, node of audience
  • Geldig bleef zolang het Secret bestond
  • Vanaf elke locatie bruikbaar was, door wie het ook in handen kreeg

Dat model werkte, maar viel hard om onder druk. Een token dat in een CI-log lekte, was eeuwig geldig. Een token gekopieerd van een laptop bleef werken nadat de eigenaar weg was. Tokens reisden mee in container registries, in screenrecordings, in plak-history. Geen rotatie, geen verloop, geen manier om de blast radius te beperken.

KEP-2799 heeft dat aangepakt. Sinds Kubernetes 1.24 maakt de API-server geen Secret-tokens meer automatisch aan voor nieuwe service accounts. De feature gate LegacyServiceAccountTokenNoAutoGeneration staat standaard aan en het gedrag is GA gegaan in 1.26. Je kunt nog steeds handmatig een Secret van het type kubernetes.io/service-account-token aanmaken als een tool echt een langlevend token nodig heeft, maar het is niet meer het standaardpatroon en niet meer het verwachte patroon.

Sinds Kubernetes 1.22: projected service account tokens

De vervanger is het bound service account token, uitgeleverd via een projected volume. Het is GA gegaan in Kubernetes 1.22 onder de feature gate BoundServiceAccountTokenVolume, wat betekent dat de vervanger al twee releases standaard was tegen de tijd dat het oude mechanisme in 1.24 wegging.

Een projected token verschilt op vijf manieren wezenlijk van een Secret-token:

  1. Tijdgebonden. Tokens hebben een exp-claim. De default-vervaltijd is een uur, en de TokenRequest API weigert tokens uit te geven met een duur korter dan 10 minuten.
  2. Audience-gebonden. Elk token noemt in de aud-claim voor welke audiences het geldig is. Een token uitgegeven voor de ene audience wordt geweigerd door API-servers die een andere audience verwachten. Dat is precies wat het veilig maakt om cluster-bound tokens uit te geven aan externe tooling, zonder dat die tokens elders bruikbaar zijn.
  3. Object-gebonden. Een pod-mounted token is gebonden aan de UID van die specifieke Pod. Wordt de Pod verwijderd, dan is het token 60 seconden na het deletion-timestamp ongeldig, ook als de JWT op zich nog binnen zijn geldigheidsvenster valt.
  4. Automatisch geroteerd. De kubelet vernieuwt het token voordat het verloopt. Concreet: de kubelet begint te roteren zodra het token ouder is dan 80 procent van zijn TTL of ouder dan 24 uur, wat het eerst gebeurt.
  5. Gemount op hetzelfde pad. Voor backwards compatibility wordt het projected token gemount op /var/run/secrets/kubernetes.io/serviceaccount/token. Code die geschreven is tegen de oude Secret-locatie blijft het bestand gewoon lezen. De inhoud van het bestand verandert; het pad niet.

De clusteroperator stelt de maximale tokenlevensduur in via de vlag --service-account-max-token-expiration op de API-server. De default is een uur. Een pod of kubectl create token-aanroeper kan een kortere duur aanvragen, maar niet langer dan het clustermaximum.

Hoe een projected volume eruitziet

De kubelet schrijft het projected volume in elke pod waarbij automountServiceAccountToken aanstaat (wat standaard voor de meeste pods geldt). Je hoeft het zelden expliciet op te geven, maar het equivalente volume ziet er zo uit:

volumes:
  - name: kube-api-access
    projected:
      sources:
        - serviceAccountToken:
            # Standaard gebonden aan de Kubernetes API-server audience
            audience: ""
            # Token-TTL in seconden; max is de waarde van de API-server-vlag
            expirationSeconds: 3600
            path: token
        - configMap:
            name: kube-root-ca.crt
            items:
              - key: ca.crt
                path: ca.crt
        - downwardAPI:
            items:
              - path: namespace
                fieldRef:
                  fieldPath: metadata.namespace

De kubelet hangt dit volume automatisch in. Het token roteert in plaats: applicaties die het bestand bij elke request opnieuw lezen, zien altijd een vers token zonder dat de pod hoeft te herstarten.

De 80-procent-rotatieregel in de praktijk

Omdat de kubelet het token vernieuwt op 80 procent van de TTL, gebruikt een applicatie die het token in geheugen cachet uiteindelijk een token dat op disk allang vervangen is. De meeste clients vangen dat op door /var/run/secrets/kubernetes.io/serviceaccount/token per request opnieuw te lezen. De officiele Kubernetes-clientlibraries (client-go, de Python-client, de Java-client) doen dat automatisch. Schrijf je je eigen client, cache het token dan niet over requests heen.

Tokenautomount uitzetten op pods die geen API-toegang nodig hebben

Een pod die nooit de Kubernetes-API aanroept, krijgt standaard toch een token gemount. Die mount is een aanvalsvlak: elk proces in de pod (een gecompromitteerde dependency dus ook) kan het token van disk lezen en zich als het service account authenticeren.

De fix is de automount uitzetten. Twee plekken om het in te stellen, waarbij de pod-spec wint als beide gezet zijn:

# Optie 1: op het ServiceAccount, geldt voor elke pod die dit account gebruikt
apiVersion: v1
kind: ServiceAccount
metadata:
  name: frontend
  namespace: production
automountServiceAccountToken: false
# Optie 2: op de pod-spec, overrulet de instelling op het ServiceAccount
apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  serviceAccountName: frontend
  automountServiceAccountToken: false

Heeft een pod echt API-toegang nodig voor een specifieke handeling, mount het token dan alleen voor die ene pod en laat de default op het ServiceAccount uit staan. De Configure Service Accounts for Pods-documentatie bevestigt dat de pod-spec voorrang krijgt als beide velden gezet zijn.

Tokens handmatig genereren voor CI/CD en externe tooling

Externe tools die zich als service account willen authenticeren, halen geen token meer uit een Secret. Twee ondersteunde patronen:

Patroon 1: kortlevende tokens via TokenRequest

# Geef een token uit dat een uur geldig is voor het ci-deployer-ServiceAccount
# in de production-namespace. Alleen de aanroeper ziet dit token; het wordt
# nergens op het cluster opgeslagen.
kubectl create token ci-deployer \
  --namespace=production \
  --duration=1h

Dit is het aanbevolen patroon voor CI/CD, scripts en elke tool die per run start en stopt. Het maximum wordt begrensd door de vlag --service-account-max-token-expiration op de API-server (default een uur). Een uitgewerkt voorbeeld van hoe je dit aan een pipeline koppelt, staat in Kubernetes least-privilege RBAC-patronen voor CI/CD.

Patroon 2: langlevend token via een Secret

Voor tools die echt niet met kortlevende tokens overweg kunnen, maak je handmatig een Secret van het type kubernetes.io/service-account-token aan:

apiVersion: v1
kind: Secret
metadata:
  name: ci-deployer-token
  namespace: production
  annotations:
    kubernetes.io/service-account.name: ci-deployer
type: kubernetes.io/service-account-token

Het control plane vult het data-veld na aanmaak met een token. Dat token verloopt niet. Behandel het dus zoals je een permanente API-key zou behandelen: bewaar het in een secrets manager, roteer op schema en trek het in (verwijder het Secret) zodra je vermoedt dat het is gelekt.

Dit patroon wordt ondersteund, maar het is de uitzondering. De meeste tools kunnen prima overweg met kortlevende tokens, en de tools die dat niet kunnen, horen op de migratielijst.

Migreren van Secret-tokens naar projected tokens

Het migratiepad ziet er voor vrijwel elk cluster hetzelfde uit:

  1. Vind de oude auto-gegenereerde tokens. De clustercontroller voegt het label kubernetes.io/legacy-token-last-used toe aan Secret-tokens waarvan hij gebruik heeft gezien. Een listing op dat label vertelt welke legacy-tokens nog actief gebruikt worden:

    # Lijst elk legacy service-account-token Secret in het hele cluster,
    # met de last-used-datum erbij zodat je ziet welke nog actief zijn.
    kubectl get secrets --all-namespaces \
      --field-selector type=kubernetes.io/service-account-token \
      -L kubernetes.io/legacy-token-last-used
  2. Identificeer de afnemer. Een token in een Secret werd door iets gelezen. Trace dat terug: een CI-pipeline, een Helm chart, een eigen controller. Het exacte pad waarop het wordt gelezen, staat meestal in de documentatie of de environment-variabelen van die tool.

  3. Vervang door kubectl create token of een projected volume. Pipelines stappen over op het uitgeven van een vers token per run. In-cluster controllers stappen over op het lezen van het projected token op /var/run/secrets/kubernetes.io/serviceaccount/token, wat ze sinds 1.22 toch al doen.

  4. Zet automountServiceAccountToken: false op elk ServiceAccount waarvan de pods de API niet aanroepen.

  5. Verwijder het Secret-token zodra niets het meer leest, na een paar dagen wachten. Het cluster doet dit op termijn vanzelf (volgende sectie), maar wachten hoeft niet als je kunt verifieren dat de afnemer weg is.

De vlag --service-account-extend-token-expiration op de API-server, default true, is het migratievangnet. Staat de vlag aan, dan krijgen tokens uit projected volumes die bijna verlopen een korte verlenging als de workload zich legacy gedraagt, zodat oude pods die nog niet met rotatie omgaan tijdens het upgradevenster blijven authenticeren. De vlag bestaat specifiek voor migraties en kan uit zodra je zeker weet dat er geen legacy-clients meer zijn.

Kubernetes 1.30: automatische opschoning van ongebruikte legacy-tokens

Ook nadat de auto-creatie van Secret-tokens stopte, hingen er in veel clusters legacy-Secrets uit het pre-1.24-tijdperk rond. Sommige werden actief gebruikt; veel niet. Het control plane kan zelf het verschil niet zien tussen een token dat niemand meer leest en een token dat een wekelijkse batchjob volgende dinsdag nodig heeft.

De legacy service account token cleaner adresseert dit. Hij is GA gegaan in Kubernetes 1.30 onder de feature gate LegacyServiceAccountTokenCleanUp. De mechaniek:

  1. Het control plane noteert de datum van laatste gebruik op elk legacy-token-Secret via het label kubernetes.io/legacy-token-last-used.
  2. Als een Secret de geconfigureerde periode niet gebruikt is (default een jaar, in te stellen via --legacy-service-account-token-clean-up-period op de kube-controller-manager), voegt de cleaner het label kubernetes.io/legacy-token-invalid-since toe met de huidige datum, en stopt het token met geaccepteerd worden.
  3. Na nog een volle periode (default opnieuw een jaar) sinds invalidering verwijdert de cleaner het Secret helemaal.

Twee veiligheidskenmerken zijn voor operators belangrijk:

  • Tokens die op dat moment door een pod gemount zijn, worden nooit als ongeldig gemarkeerd. De cleaner slaat Secrets over die nog door een pod worden gerefereerd. Een langlopende workload met het Secret gemount, krijgt zijn token dus niet onder zich vandaan getrokken.
  • Invalidatie is omkeerbaar. Faalt een tool met authenticatie nadat het Secret invalid is gemarkeerd, dan kan een admin het label kubernetes.io/legacy-token-invalid-since verwijderen om het Secret tijdelijk weer in dienst te nemen terwijl de migratie wordt afgerond. Dat is een tijdelijke oplossing, geen langetermijn-fix.

Sinds 1.30 hoef je legacy-tokens niet meer handmatig te inventariseren. Het cluster doet dat voor je. Maar precies daarom moet je zorgen dat je monitoring authenticatiefouten oppikt: leunt een tool stilletjes op een legacy-token, dan is het foutscenario een 401 van de API-server, niet een waarschuwing vooraf.

Token-authenticatiefouten debuggen

Begint een workload die het eerder deed opeens met 401 Unauthorized, dan zijn de oorzaken voorspelbaar:

  • Cluster is voorbij 1.24 gegaan en de workload las een Secret-token dat niet meer bestaat. Inspecteer het Secret: is het veld data.token afwezig of leeg, dan heeft het auto-generatie-mechanisme er geen aangemaakt. Genereer expliciet een vers token of laat de workload het projected token lezen.
  • Token dat de kubelet mount is geroteerd en de applicatie cachet de oude. Check de tokenafhandeling in de applicatie. Lees het bestand bij elke request opnieuw; cache niet.
  • Token is uitgegeven voor de verkeerde audience. Een token uitgegeven via kubectl create token --audience=https://mijn-ander-systeem wordt door de API-server geweigerd omdat de audience niet matcht. Gebruik de default-lege audience voor API-server-authenticatie, en een niet-default-audience alleen als een extern systeem dat expliciet eist.
  • Pod is verwijderd en het token is daardoor ongeldig. De grace-periode van 60 seconden na verwijdering is bewust kort. Workloads die als onderdeel van graceful shutdown nog API-calls willen doen, moeten die calls voor het deletion-timestamp afhandelen, niet erna.
  • Legacy-token is door de 1.30-cleaner als ongeldig gemarkeerd. Check het Secret op het label kubernetes.io/legacy-token-invalid-since. Staat het er, dan heeft de cleaner het token geinvalideerd. Migreer de afnemer of, als tijdelijke maatregel, verwijder het label.

Voor diepere RBAC-gerelateerde autorisatiefouten (403 Forbidden) loopt de RBAC-debugworkflow door hoe je met kubectl auth can-i achterhaalt welke permissie ontbreekt.

Wat service account tokens NIET zijn

Het model is makkelijk verkeerd te begrijpen. Drie claims kloppen niet, en zijn de moeite waard om expliciet te ontkrachten.

"Service account tokens verlopen niet." Dat klopte voor de oude Secret-tokens. Het klopt niet voor projected tokens, die standaard na een uur verlopen en automatisch roteren. Gaat je code ervan uit dat een service-account-token een langlevende credential is, dan leest die code een ander systeem dan het systeem van vandaag.

"Elke pod heeft een service-account-token nodig." Een pod die de Kubernetes-API niet aanroept, heeft geen gemount token nodig. automountServiceAccountToken: false zetten verwijdert een echt aanvalspad zonder dat het gedrag van de pod verandert. De default staat aan, maar de default is voorzichtig, niet correct.

"Je kunt een token over clusters heen hergebruiken." Tokens worden getekend met de private key van het cluster en gevalideerd met de bijbehorende public key. Een token uit cluster A is voor cluster B een willekeurige reeks bytes. Daarbovenop maakt audience binding zelfs hergebruik binnen een cluster beperkt: een token uitgegeven voor de ene audience kan niet authenticeren tegen een andere. Een draagbaar service-account-token dat over clusters heen werkt, bestaat niet, by design.

"automountServiceAccountToken: false breekt workload identity." Dat is niet zo. De workload-identity-systemen op EKS, GKE en AKS gebruiken een aparte projected volume voor hun cloud-bound token. De standaard mount uitzetten verwijdert alleen het Kubernetes-API-token, en dat is precies wat je wilt voor een pod die zich tegen een cloudprovider authenticeert maar niet rechtstreeks tegen de Kubernetes-API.

"Een Secret van het type kubernetes.io/service-account-token is hetzelfde als het projected token." Dat zijn verschillende mechanismen met verschillende beveiligingseigenschappen. Het Secret-token is langlevend, heeft geen audience binding en is niet aan de levenscyclus van een pod gebonden. Het projected token is kortlevend, audience-gebonden, pod-gebonden en wordt geroteerd. Allebei authenticeren ze tegen dezelfde API-server, maar alleen het projected token heeft de veiligheidseigenschappen die met KEP-1205 binnenkwamen.

Wanneer escaleren

Lost een token-probleem zich niet op via de stappen hierboven, verzamel dan het volgende voordat je om hulp vraagt:

  • De exacte 401- of 403-foutmelding van de API-server (volledige tekst, inclusief eventuele WWW-Authenticate-header)
  • Output van kubectl describe sa <naam> -n <namespace> en kubectl get secrets -n <namespace> -l kubernetes.io/service-account-name=<naam>
  • Bij pod-side-problemen: kubectl describe pod <naam> en de inhoud van /var/run/secrets/kubernetes.io/serviceaccount/token (decoded met jwt.io om de claims te inspecteren, nooit om een echt productietoken op een publieke site te debuggen)
  • Kubernetes-versie (kubectl version)
  • Token-relevante API-server-vlaggen: --service-account-issuer, --service-account-max-token-expiration, --service-account-extend-token-expiration, --service-account-key-file
  • Of het cluster custom authenticatie- of autorisatiewebhooks heeft die het token kunnen weigeren nadat de API-server het al heeft gevalideerd

Met die informatie kan iemand "het token werd door de API-server geweigerd" scheiden van "het token werd geaccepteerd maar RBAC wees de actie af" van "een admission webhook at de request al voor de autorisatielaag erbij kwam". Dat zijn drie verschillende problemen met drie verschillende fixes.

Waar nu naartoe

Terugkerende server- of deploymentproblemen?

Ik help teams productie betrouwbaar maken met CI/CD, Kubernetes en cloud—zodat fixes blijven en deploys geen stress meer zijn.

Bekijk DevOps consultancy

Doorzoek deze site

Begin met typen om te zoeken, of blader door de kennisbank en blog.