6 Nov 2026 · 9 min read
K8s Deep Dive Ep 2: Kube API Server
What does the api server do? This post explores the various authentication and authorization process for kubectl run command.
- kubernetes
- kind
- api-server
Introduction
In Ep 1 we found that kubectl run sends the pod as one POST
request and exits. We also learned that this request is sent to a component in Kubernetes called the
api server. Think of api-server as the gateway that all clients will go through if they would like
to communicate to the cluster.
In this article, we will take a look on what does the api server actually does.
Responsibilities of API Server
If you think about a gateway, what would be one of the most important feature it needs to have? If you answer Authentication or Authorization, then you're absolutely correct! The Kube API Server is the main compoennt that deals with those, but it does more than that. To be precise, here are the list of responsibilities that an api-server does:
- Authentication
- Authorisation
- Rate Limiting (API Priority & Fairness)
- Mutating Admission
- Validating Admission
- Writing to ETCD
Let's look at them one by one
Authentication (Who are you?)
The first thing the server needs to know is who is sending the request and whether they have access.
To see how this work, we can try just sending the HTTP command using curl directly instead of
kubectl using a random bearer token. The -k flag tells curl not to check the server's
certificate, because curl doesn't trust the certificate authority kind created. Use the port
from your own ~/.kube/config.
❯ curl -sk https://127.0.0.1:58827/api/v1/namespaces/default/pods
{
...
"message": "pods is forbidden: User \"system:anonymous\" cannot list resource \"pods\" in API group \"\" in the namespace \"default\"",
"reason": "Forbidden",
...
"code": 403
}The server didn't turn us away for having no credentials. It treated us as a user called
system:anonymous and passed the request on to the next stage, which checks what that user is
allowed to do. system:anonymous can't list pods, so that stage refused us with a 403. Anonymous
access is on by default in kind (this may vary depending on what implementation of Kubernetes are
you using).
Let's try again with a made up token.
❯ curl -sk -H 'Authorization: Bearer random-token-123' https://127.0.0.1:58827/api/v1/namespaces/default/pods
{
...
"message": "Unauthorized",
"reason": "Unauthorized",
"code": 401
}This request didn't get as far. The API server tried each of its authentication methods, none accepted the token, and it stopped the request with a 401 instead of falling back to the anonymous user. A wrong token gets rejected, and no token at all gets you the anonymous user.
So how does kubectl get through? kind put a client certificate and its private key in
~/.kube/config, and kubectl presents the certificate on every request. curl can do the same if
we give it those files. The kubeconfig stores them base64 encoded, so let's decode them. --raw
stops kubectl from hiding the certificate data, and --minify keeps only the current context, so
[0] picks our cluster and user.
❯ kubectl config view --raw --minify -o jsonpath='{.users[0].user.client-certificate-data}' | base64 -d > client.crt
❯ kubectl config view --raw --minify -o jsonpath='{.users[0].user.client-key-data}' | base64 -d > client.key
❯ kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crtclient.crt and client.key prove who we are. ca.crt is the certificate authority kind
created. With it, curl can check the server's certificate, so we can drop -k.
❯ curl -s --cert client.crt --key client.key --cacert ca.crt https://127.0.0.1:58827/api/v1/namespaces/default/pods
{
"kind": "PodList",
"apiVersion": "v1",
"metadata": {
"resourceVersion": "196262"
},
"items": [
{
"metadata": {
"name": "nginx",
"namespace": "default",
...
},
"spec": { ... },
"status": { ... }
}
]
}Success! We managed to pass through the authentication gate. We can actually check who did we authenticate us by asking the server
❯ kubectl auth whoami
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [kubeadm:cluster-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id [X509SHA256=e4d8c91a...]You might be wondering where does the username and groups come from? It reads the username and the group from the certificate.
Let's now delete the key and certs because anyone with the files has full control of the cluster. This doesn't really matter if you're running a local kind, but if you're using a real cluster you should delete these file.
❯ rm client.crt client.key ca.crtRate Limiting
After authentication, the request is put in a queue called API Priority and Fairness before the authorisation check stage. Think of this stage as checking a rate limit to the client's request. The goal is to prevent one client overwhelming the server so much that it prevents other clients from sending requests to the api-server. Clients that exceed the permitted rate limit can experience increased latency or be dropped with an HTTP 429 (Too Many Requests) error.
Authorization (What are you allowed to do?)
After it passes the API Priority check, the request goes through an authorisation check. Knowing who
we are doesn't say what we can do and authorisation ensures the user only execute commands they are
allowed to do. On this cluster it's mostly handled by RBAC (role-based access control), where rules
grant a user or group permission to do certain things. As you can see from the earlier
kubectl auth whoami command, our group is bound to the cluster-admin role, which can do
practically anything.
❯ kubectl get clusterrolebinding kubeadm:cluster-admins -o wide
NAME ROLE AGE USERS GROUPS SERVICEACCOUNTS
kubeadm:cluster-admins ClusterRole/cluster-admin 6d9h kubeadm:cluster-adminsWhat would it look like if we have a more limited user? We can try using a service account called
default. Service account is an identity for a software running on the cluster, think of them as a
user account assigned to your application. Let's check if the account has access to create pods
❯ kubectl auth can-i create pods --as=system:serviceaccount:default:default
noPerfect. So we should see some failure when we try to create a pod using this account
❯ kubectl run y --image=nginx --as=system:serviceaccount:default:default -v=6
...
"Response" verb="POST" url="https://127.0.0.1:58827/api/v1/namespaces/default/pods?fieldManager=kubectl-run" status="403 Forbidden" milliseconds=5
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:default:default" cannot create resource "pods" in API group "" in the namespace "default"Glad to see our authorization check working. Notice what the error talks about. It mentions the
user, the verb create, the resource pods and the namespace. It says nothing about our pod,
because authorization doesn't look at the pod or any of the pod configuration parameters.
Mutating Admission
When you run the kubectl run there is a feature for dry-run that lets you preview what the
request will look, and there are 2 input options, a --dry-run=client or --dry-run=server. What's
the difference? Let's compare them
❯ kubectl run dry-demo --image=nginx --port=80 --dry-run=client -o yaml > dry-client.yaml
❯ kubectl run dry-demo --image=nginx --port=80 --dry-run=server -o yaml > dry-server.yaml
❯ diff dry-client.yaml dry-server.yaml
> creationTimestamp: "2026-10-31T16:15:15Z"
> generation: 1
> namespace: default
> uid: e998dbcd-64f7-4ab0-b54f-ac7c4422b47b
> imagePullPolicy: Always
> protocol: TCP
> terminationMessagePath: /dev/termination-log
> terminationMessagePolicy: File
> volumeMounts:
> - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
> name: kube-api-access-88ghc
> readOnly: true
> enableServiceLinks: true
> preemptionPolicy: PreemptLowerPriority
> priority: 0
> schedulerName: default-scheduler
> securityContext: {}
> serviceAccount: default
> serviceAccountName: default
> terminationGracePeriodSeconds: 30
> tolerations:
> - effect: NoExecute
> key: node.kubernetes.io/not-ready
> operator: Exists
> tolerationSeconds: 300
> - effect: NoExecute
> key: node.kubernetes.io/unreachable
> operator: Exists
> tolerationSeconds: 300
> volumes:
> - name: kube-api-access-88ghc
> projected:
...We see how the API server added a lot of new fields to our request. There are actually 2 sources where these values come from:
The first is plain defaults. When the API server decodes the request body, it fills in the fields we
left out, such as protocol: TCP and terminationGracePeriodSeconds: 30. They're listed in
defaults.go.
imagePullPolicy: Always is one of them, and it's set to Always because we didn't give the image
a tag.
The second is admission control, a set of plugins that run inside the API server after authorization and before the write. The phase that runs first is mutating admission, which means these plugins are allowed to change the pod. Three of them affected our request:
ServiceAccountsetserviceAccountName: defaultand added thekube-api-accessvolume, which gives the pod a token for talking to the API server.Prioritysetpriority: 0andpreemptionPolicy.DefaultTolerationSecondsadded the two tolerations.
The plugins are compiled into the kube-apiserver binary, and about two dozen are on by default. We
can see that kind adds one more, along with the authorization modes it uses.
❯ docker exec lab-control-plane grep -E 'admission|authorization-mode' /etc/kubernetes/manifests/kube-apiserver.yaml
- --authorization-mode=Node,RBAC
- --enable-admission-plugins=NodeRestrictionYou can add your own mutating step with a webhook, which is an HTTP call from the API server to a service you run.
Built-in validation
After modifying the pod request, the API server then checks whether the request is valid. Let's try
giving it a restartPolicy that doesn't exist. The --overrides flag merges JSON into the pod
kubectl builds.
❯ kubectl run bad-pod --image=nginx --overrides='{"apiVersion":"v1","spec":{"restartPolicy":"Sometimes"}}' -v=6
...
"Response" verb="POST" url="https://127.0.0.1:58827/api/v1/namespaces/default/pods?fieldManager=kubectl-run" status="422 Unprocessable Entity" milliseconds=4
The Pod "bad-pod" is invalid: spec.restartPolicy: Unsupported value: "Sometimes": supported values: "Always", "OnFailure", "Never"This time the server read the pod. 422 means the request was understood, but the object is
invalid. This check is the same on every cluster because it comes with the pod's definition. But
what if we want to have customised rules for our cluster?
Validating Admission
That's where validating admission plugins come into play. These plugins can't change the pod. They
can only accept or reject it. They lets us write rules without running any service, using a
ValidatingAdmissionPolicy.
The rule is written in CEL (Common Expression Language), a small language for expressions.
Let's try to add a custom rule: that every image in the default namespace must have a tag. Save
this as require-tag.yaml. While it's applied, any new pod in default with an untagged image will
be refused, so we'll remove it at the end.
# require-tag.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-image-tag
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
validations:
- expression: "object.spec.containers.all(c, c.image.contains(':'))"
message: "pin an image tag"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-image-tag
spec:
policyName: require-image-tag
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: defaultApply it and wait a few seconds for the API server to pick it up.
❯ kubectl apply -f require-tag.yaml
❯ kubectl run test-pod --image=nginx -v=6
...
"Response" verb="POST" url="https://127.0.0.1:58827/api/v1/namespaces/default/pods?fieldManager=kubectl-run" status="422 Unprocessable Entity" milliseconds=11
The pods "test-pod" is invalid: : ValidatingAdmissionPolicy 'require-image-tag' with binding 'require-image-tag' denied request: pin an image tag
❯ kubectl run with-tag --image=nginx:1.27
pod/with-tag createdOur rule works! Notice that it returned a 422, not a 403. We were allowed to create pods, but
this particular pod broke a rule.
Writing to etcd
Only after the request pass through all the previous steps, then it will enter the final stage, writing it to the etcd database. Let's try a valid request this time:
❯ kubectl run nginx-demo --image=nginx:1.27 --port=80
pod/nginx-demo createdOnce it's written to the ETCD database, then other components in kubernetes will then process it further, which we will cover in the next article. However, this does not mean there can be no error at this stage. Let's try sending the same request again.
❯ kubectl run nginx-demo --image=nginx:1.27 --port=80 -v=6
...
"Response" verb="POST" url="https://127.0.0.1:58827/api/v1/namespaces/default/pods?fieldManager=kubectl-run" status="409 Conflict" milliseconds=6
Error from server (AlreadyExists): pods "nginx-demo" already existsHere we see etcd rejecting the second write because pod names needs to be unique in a given namespace.
Putting it together
Let's zoom into the kube-apiserver box from Ep 1's map. Here is what a create request goes
through.
Here's what stopped each of our requests:
- A made up token stopped at authentication with
401. - No token became
system:anonymous, and authorization refused it with403. - The
defaultservice account stopped at authorization with403. restartPolicy: Sometimesstopped at schema validation with422.- An image with no tag stopped at our policy with
422. - A name already in use stopped at the write with
409.
There's one thing this still doesn't solve. All we have now is a record in etcd. Nothing has told the scheduler, and no container is running.
In Ep 3 we'll look at that record in etcd, and at how the scheduler finds out about it without anyone calling it.
Glossary
| Terms | Definition |
|---|---|
| Authentication | the stage that works out who sent a request. If the credentials are wrong, the API server answers 401 |
| Authorization | the stage that decides whether that user may perform the action. If not, the API server answers 403 |
| RBAC | role-based access control, where rules grant users or groups permission to act on certain resources |
| Service account | an identity for software running in the cluster. Every namespace has one called default |
| API Priority and Fairness | the queue that stops one busy client from starving others. It answers 429 when it can't take a request |
| Defaulting | the API server filling in fields a request left out, such as protocol: TCP |
| Admission control | plugins inside the API server that can change or refuse a request after authorization and before the write |
| Mutating admission | the admission phase whose plugins may change the object |
| Validating admission | the admission phase whose plugins may only accept or refuse the object |
| Schema validation | the built-in check that an object is valid for its type, such as restartPolicy being one of the allowed values |
| ValidatingAdmissionPolicy | an object that adds a validating admission rule, written in CEL, without running a separate service |
| CEL | Common Expression Language, a small language for writing expressions such as the rules in a policy |
| Webhook | an HTTP call from the API server to a service you run, used to add your own admission logic |
| Dry run | a request that does not store anything. A client dry run stays on your machine, and a server dry run runs the API server's stages |
| Taint | a mark on a node that keeps away pods that don't tolerate it |
| Toleration | a field on a pod that lets it stay on or be placed on a node with a matching taint |
resourceVersion | the version of an object in storage, set by the API server when it writes the object |
Share