Understanding Kubernetes Service Accounts (The Identity Every Pod Uses)

Understanding Kubernetes Service Accounts (The Identity Every Pod Uses)

1 3 13
calendar_today agoschedule9 min read

Most of us learn about Kubernetes users before we learn about Service Accounts. That's actually where the confusion starts Service Accounts end up looking like "just another kind of user," and you file them away without really getting what makes them different.

They're not just another user. They solve a completely different problem.

Today I learned that human users authenticate from outside the cluster — you, sitting at your laptop, running kubectl with your certificate or your login. But Pods run inside the cluster, and they often need to talk to the Kubernetes API too — to list other pods, read a ConfigMap, watch for changes, whatever. A Pod can't exactly type in a username and password. It needs its own identity.

That identity is called a Service Account.

User vs. Service Account

Before touching a single command, it's worth seeing the two side by side:

User Service Account
Used by humans Used by Pods
External to Kubernetes Managed by Kubernetes
Usually authenticated using certificates Authenticated using tokens
You create it outside the cluster Kubernetes creates and manages it

Keep that table in your head — everything below is really just this table playing out in the terminal.

Step 1 — Checking Existing Service Accounts

❯ k get sa
NAME      AGE
default   38d

Notice I didn't create this. It was already there, 38 days old — as old as the namespace itself.

That's the first thing to internalize: every namespace automatically gets a Service Account called default. You don't ask for it. If you create a Pod and never say which Service Account it should use, Kubernetes quietly attaches this one for you. Keep that in mind — it comes back to bite (in a good way) later in this post.

Step 2 — Creating My Own Service Account

❯ k create sa build-sa
serviceaccount/build-sa created

❯ k describe sa build-sa
Name:                build-sa
Namespace:           default
Labels:              <none>
Annotations:         <none>
Image pull secrets:  <none>
Events:              <none>

Simple enough — I now have a second identity, build-sa, sitting in the default namespace, ready to be attached to Pods later.

But here's the question that trips a lot of beginners up: why doesn't it have a token?

If you learned Kubernetes a few years ago, or you're reading an older tutorial, you'll expect describe sa to show a Tokens: field pointing at an auto-generated Secret. Not anymore. Newer versions of Kubernetes stopped automatically minting long-lived Secret-based tokens for every Service Account, mainly for security reasons — a token that never expires and just sits around as a Secret is a standing risk if it leaks. Instead, Kubernetes now prefers short-lived, automatically-rotated tokens issued on demand.

Since I specifically wanted a durable token I could inspect and use for --as testing, I had to create it myself.

Step 3 — Creating a ServiceAccount Token Secret

I wrote a secret.yaml that, at minimum, needs this annotation:

metadata:
  annotations:
    kubernetes.io/service-account.name: build-sa

This one line is doing all the work. It tells the Kubernetes controller: "This Secret isn't just a random Opaque blob — it's a token for the build-sa Service Account." Because of that annotation, a built-in controller notices the Secret, generates a signed token for build-sa, and populates the Secret with it automatically.

❯ k apply -f secret.yaml
secret/build-robot-secret created

❯ k get secret
NAME                 TYPE                                  DATA   AGE
backend-user         Opaque                                1      5d3h
build-robot-secret   kubernetes.io/service-account-token   3      14s

See the TYPE column — kubernetes.io/service-account-token, not Opaque. That type is exactly what tells Kubernetes "treat this Secret specially and fill it with SA credentials."

Inspecting the Secret

❯ k describe secret build-robot-secret
Name:         build-robot-secret
Namespace:    default
Labels:       <none>
Annotations:  kubernetes.io/service-account.name: build-sa
              kubernetes.io/service-account.uid: d065d358-58fa-44a6-9656-8263e0fa05eb

Type:  kubernetes.io/service-account-token

Data
====
ca.crt:     1107 bytes
namespace:  7 bytes
token:      eyJhbGciOiJSUzI1NiIs... (truncated)

Three fields, each with a job:

  • ca.crt — the cluster's certificate authority. Whoever holds this Secret can verify they're really talking to the real API server, not an impersonator.
  • namespace — just the namespace this identity belongs to (default), so any client using this Secret knows where it "lives."
  • token — the important one. This is a JWT (JSON Web Token), signed by the cluster. This is what a Pod, script, or client hands to the API server to say "I am build-sa, and here's proof." I've truncated the token above — treat these things like passwords, not like log output, even in a lab cluster.

If you decode a Service Account JWT (there's nothing secret about the structure, just don't paste your actual token online), you'll find claims like the issuer, the namespace, and a sub field that looks like system:serviceaccount:default:build-sa. That string is the Service Account's real, canonical identity inside Kubernetes — remember it, it matters in a minute.

Testing Permissions

Now the interesting part. I wanted to see what build-sa could actually do, without creating a Pod yet:

❯ k get pods --as build-sa
Error from server (Forbidden): pods is forbidden: User "build-sa" cannot list resource "pods" in API group "" in the namespace "default"

Two things to unpack here.

First — what does --as actually do? It's easy to assume you're "logging in" as build-sa. You're not. --as is impersonation. You're still using your own admin credentials to talk to the API server, but you're telling it: "Evaluate this request as if it came from build-sa instead of me." The API server checks whether your account is allowed to impersonate build-sa (admin usually is), and if so, it re-runs the authorization check as that identity. It's a testing and debugging superpower — you get to try on someone else's permissions without actually needing their credentials.

Second — why Forbidden? This is the single most important lesson in this whole exercise: the Service Account existed. The token existed. Authentication succeeded. Authorization failed.

Those are two separate gates in Kubernetes:

  1. Authentication — "Who are you?" build-sa proved this fine, via impersonation.
  2. Authorization — "What are you allowed to do?" Nothing had granted build-sa any permissions at all, so RBAC said no.

A lot of beginners collapse these into one concept. They're not the same thing, and separating them in your head will save you hours of confused debugging later.

Creating the Role

❯ k create role build-role \
--verb=list,get,watch \
--resource=pod
role.rbac.authorization.k8s.io/build-role created

Breaking down the flags:

  • role build-role — I'm creating a Role, which is a namespaced set of permissions (as opposed to a ClusterRole, which applies cluster-wide).
  • --verb=list,get,watch — the specific actions this Role allows. Not create, not delete — just read-style operations.
  • --resource=pod — the Kubernetes resource type these verbs apply to.

On its own, a Role does nothing. It's a permission template sitting unused until something binds an identity to it.

Creating the RoleBinding

❯ k create rolebinding rb \
--role=build-role \
--user=build-sa
rolebinding.rbac.authorization.k8s.io/rb created

This is the glue: it binds the build-role permissions to the identity build-sa.

One honest caveat worth flagging, since I want this post to be technically accurate and not just "it worked so it's fine": I used --user=build-sa here, and it worked in this exercise because I was testing purely through impersonation with --as build-sa, and --as treats whatever string you give it as a plain username.

But that's not actually how a real Service Account identifies itself to the cluster. Its canonical identity is system:serviceaccount:<namespace>:<name> — in my case, system:serviceaccount:default:build-sa. In production, the standard, correct way to bind a RoleBinding to a Service Account is:

--serviceaccount=default:build-sa

If I'd actually mounted this token inside a real client and tried to use it (rather than testing through admin impersonation), a binding made with --user=build-sa would not match that Service Account's real identity, and the request would still be Forbidden. So: my command worked for this specific impersonation test, but if you're setting this up for a real workload, use --serviceaccount=<namespace>:<name> instead. Good to know the difference exists before it costs you a debugging session.

Testing Again

❯ k get pods --as build-sa
No resources found in default namespace.

No error this time — just an empty result, because there genuinely were no pods yet. Authentication and authorization both passed. That's the fix landing.

Creating a Deployment

❯ k create deploy test --image=nginx
deployment.apps/test created

Worth pointing out deliberately: I created this Deployment as myself (admin), not as build-sa. build-sa only has list, get, and watch on pods — nowhere near enough permission to create a Deployment. If I'd tried k create deploy test --image=nginx --as build-sa, it would have been Forbidden too.

Watching the Pod

❯ k get po -w --as build-sa
NAME                    READY   STATUS    RESTARTS   AGE
test-56848fd9dc-fhdjv   1/1     Running   0          89s

The -w flag means watch — instead of printing the pod state once and exiting, kubectl keeps the connection open and streams updates as the pod's status changes. Combined with --as build-sa, this also confirms build-sa's watch verb from the Role is working, not just list and get.

Going Inside the Pod

This is where it got genuinely interesting.

❯ k exec -it test-56848fd9dc-fhdjv bash
error: exec [POD] [COMMAND] is not supported anymore. Use exec [POD] -- [COMMAND] instead

I hit this error a few times before it clicked. Older kubectl versions let you run kubectl exec pod bash and it would figure out where your flags ended and the container's command began. Modern kubectl refuses to guess — it wants an explicit -- to separate "arguments meant for kubectl" from "the command to run inside the container." So:

❯ k exec -it test-56848fd9dc-fhdjv -- bash
root@test-56848fd9dc-fhdjv:/#

That extra -- is the whole fix. Everything after it belongs to the container, not to kubectl.

The Most Interesting Discovery

Once inside, I went looking for exactly the kind of thing this Pod would need to talk to the API server — and there it was:

root@test-56848fd9dc-fhdjv:/var/run/secrets/kubernetes.io/serviceaccount# ls -lrt
total 0
lrwxrwxrwx 1 root root 12 Jul  7 14:38 token -> ..data/token
lrwxrwxrwx 1 root root 16 Jul  7 14:38 namespace -> ..data/namespace
lrwxrwxrwx 1 root root 13 Jul  7 14:38 ca.crt -> ..data/ca.crt

Same three files as the Secret I inspected earlier — token, namespace, ca.crt — automatically mounted at /var/run/secrets/kubernetes.io/serviceaccount, with no configuration from me at all. Kubernetes does this for every Pod by default, via a projected volume, so that any process running inside the container can immediately authenticate to the API server if it needs to (this is how things like controllers and operators, running as Pods themselves, talk back to Kubernetes).

Now the obvious beginner question, and the one I asked myself immediately: "Wait — I created build-sa. Why does the token in here say default?"

Sure enough, decoding what's inside shows a sub claim of system:serviceaccount:default:default — the default Service Account, not build-sa.

The answer is almost anticlimactic once you see it: my Deployment spec never included serviceAccountName: build-sa. I never told Kubernetes which Service Account this Pod should use — so it fell back to the one thing every namespace already has, the one from Step 1. The default Service Account.

This is, genuinely, the most valuable lesson from the whole exercise: creating a Service Account doesn't automatically attach it to anything. You have to explicitly wire it into a Pod (or Deployment, or Job, etc.) with serviceAccountName — or Kubernetes will silently keep using default on your behalf, and you won't get an error telling you. You'll just quietly have the wrong permissions, or, worse in production, the default Service Account might have more access than you meant to grant that workload.

Key Takeaways

  • Service Accounts are identities for Pods — not humans.
  • Every namespace automatically gets a default Service Account.
  • Service Accounts authenticate using tokens (JWTs), not certificates.
  • Newer Kubernetes versions don't auto-create long-lived token Secrets anymore — you create them explicitly when you need one.
  • RBAC still governs what a Service Account is allowed to do, exactly like it does for users.
  • --as impersonates another identity for testing — it doesn't log you in as them.
  • Authentication and authorization are two separate checks: proving who you are is not the same as being allowed to do something.
  • Bind RoleBindings to Service Accounts with --serviceaccount=<namespace>:<name> in real setups, not --user=<name>.
  • Pods automatically get their Service Account's credentials mounted at /var/run/secrets/kubernetes.io/serviceaccount — no extra config needed.
  • If you don't set serviceAccountName on a Pod, Kubernetes silently uses default. Always check.
Part 6 of 6 in My Kubernetes Journey
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Understanding Kubernetes ClusterRoles and ClusterRoleBindings

AYANFE - Jul 7

Understanding Kubernetes RBAC: Roles, RoleBindings, and and Client Certificates

AYANFE - Jul 6

Kubernetes Autoscaling: HPA vs VPA Explained With Hands-On Practice

AYANFE - Jul 1

Check out this article for beginners on Kubernetes

Onlyfave - Jan 17, 2025

Have you ever left a tech meetup feeling like everyone else understood the conversation except you?

Ijay - Jul 2
chevron_left
308 Points17 Badges
Abuja,Nigeria.
8Posts
3Comments
5Connections
A gentleman with a rough edge.

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!