Audit logs
An audit policy that records what matters.
You can operate inside a cluster for weeks and leave nothing behind, if audit logging is off. On a fresh cluster it usually is off, or it's set to a starter policy that records almost nothing worth keeping. Falco watches the host and its syscalls: a shell spawns, a binary you didn't expect starts running. The Kubernetes API (Application Programming Interface) server audit log watches a different layer. It records who asked the control plane to do what, and when. Put the two side by side and you cover both halves of one investigation. Falco tells you a shell opened in payments-api. The audit log tells you which identity ran that exec at 15:02, and that the same identity listed three Secrets a minute later. It's a structured, time-ordered record of every request the API server handled. But you get it only if you write a policy first. With no policy loaded, the API server records nothing.
An audit policy is a list of rules the API server reads top to bottom, first match wins, the way a bouncer works down a guest list until a name matches. Each rule assigns one of four levels, and a level is just how much you write down about a request. Think of a front-desk visitor log. None means you don't record the visit at all. Metadata records who arrived, when, and who they came to see, but nothing they said. Request adds the body, what they actually asked for. RequestResponse keeps both sides of the exchange, the question and the answer. Watch out for one trap here: Request and RequestResponse both write the request body, and for a Secret the create request body is the Secret data itself. That's why sensitive resources belong at Metadata, not one notch higher. The whole skill is recording enough to reconstruct an incident later without drowning in noise, or worse, copying Secret contents onto disk where they don't belong. So sensitive resources sit at Metadata, the writes that carry a body worth keeping get RequestResponse, and routine background chatter gets None.
apiVersion: audit.k8s.io/v1kind: Policyrules:- level: None # drop kube-proxy's constant endpoint/service watchesusers: ["system:kube-proxy"]verbs: ["watch"]resources: [{ group: "", resources: ["endpoints", "services"] }]- level: None # drop kube-system service-account chatteruserGroups: ["system:serviceaccounts:kube-system"]- level: Metadata # secrets: record access, never contentsresources: [{ group: "", resources: ["secrets", "configmaps"] }]- level: Metadata # exec/attach: the command is in the URI, there is no bodyresources: [{ group: "", resources: ["pods/exec", "pods/attach"] }]- level: RequestResponse # rbac writes: keep the exact object submittedverbs: ["create", "update", "patch", "delete"]resources: [{ group: "rbac.authorization.k8s.io", resources: ["rolebindings", "clusterrolebindings"] }]- level: Metadata # everything else, minimally
Wire the policy into the API server and point it at a log file, with rotation caps so a busy day can't fill the disk and take the control plane down with it. For detection anyone will actually act on, stream the events off the box too. A webhook backend forwards each event to your SIEM (Security Information and Event Management platform, the tool your responders live in). Run it in batch mode so a slow SIEM can never stall an API request while the server waits for an acknowledgement. It's the same logic as a security camera that uploads its footage off-site instead of trusting the one tape in the lobby: a local audit log on a compromised node is one of the first files an intruder trims or wipes, so you want the record sitting somewhere they can't reach before they even think to look for it.
- --audit-policy-file=/etc/kubernetes/audit/policy.yaml- --audit-log-path=/var/log/kubernetes/audit.log- --audit-log-maxage=30 # days to keep- --audit-log-maxbackup=10 # rotated files to keep- --audit-log-maxsize=100 # MB per file before rotating- --audit-webhook-config-file=/etc/kubernetes/audit/webhook.yaml- --audit-webhook-mode=batch # non-blocking: batch events to the SIEM
Restarting the API server with new flags proves nothing on its own. A flag can be present and the control still be useless, because the flag turns logging on but says nothing about whether your policy captures the right things at the right levels. So prove it twice. First, that the flags line up with the CIS (Center for Internet Security) Kubernetes Benchmark, the community baseline most auditors grade you against. Then, separately, that real events actually land at the levels you intended. Neither check alone is enough. Pass the benchmark with a policy that logs nothing useful and you've got a green report and a blind cluster.
$ kube-bench run --targets master | grep audit-log[PASS] 1.2.22 Ensure that the --audit-log-path argument is set (Automated)[PASS] 1.2.23 Ensure that the --audit-log-maxage argument is set to 30 or as appropriate (Automated)[PASS] 1.2.24 Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate (Automated)[PASS] 1.2.25 Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate (Automated)
kube-bench checks the flags against that benchmark and nothing more. It can't see whether your policy behaves the way you wrote it, so it will happily pass a cluster whose policy logs everything or nothing. That gap between a passing report and a policy that actually works is where most teams pick up a false sense of safety. For the real test you have to read the log. Trigger a Secret read and an exec by hand, then check what level each one landed at, and whether any request body got written to disk.
$ kubectl get secret db-creds -n payments >/dev/null # generate a read$ kubectl exec -it payments-api-7d9 -n payments -- sh # generate an exec, then exit$ tail -n 300 /var/log/kubernetes/audit.log \| jq -c 'select(.objectRef.resource=="secrets" and .verb=="get")| {level, user:.user.username, body:(has("responseObject"))}' | tail -1{"level":"Metadata","user":"alice","body":false}$ tail -n 300 /var/log/kubernetes/audit.log \| jq -c 'select(.objectRef.subresource=="exec")| {level, verb, uri:.requestURI}' | tail -1{"level":"Metadata","verb":"create","uri":"/api/v1/namespaces/payments/pods/payments-api-7d9/exec?command=sh&stdin=true&tty=true"}
The Secret read shows body:false, so its contents never touched the log. You know alice read db-creds; you don't learn what it held. If that field came back true, you'd stop and fix the policy before shipping one more event, because every forwarded record would be carrying plaintext you'd then have to scrub out of the SIEM. The exec event puts the command right there in the URI, which is the part you actually need, and it is also all you can get. An exec upgrades into a streaming connection, so there is no request or response body for the API server to write down: RequestResponse on pods/exec hands you these same fields at several times the storage cost, because requestURI is already recorded at Metadata. What gets typed into that shell is Falco's job, not the audit log's. Both checks behaved, then. What none of this tells you is whether the events ever left the node, so read the backend's own counters next.
$ kubectl get --raw /metrics \| grep -E '^apiserver_audit_(event|error|requests_rejected)_total'apiserver_audit_event_total 48213apiserver_audit_error_total{plugin="buffered"} 0apiserver_audit_requests_rejected_total 0
Read those three for what they are, not for what you hope. apiserver_audit_event_total counts the events your policy generated, not events any backend delivered. apiserver_audit_requests_rejected_total counts API requests the server refused because an audit backend errored, which is a different failure entirely. The one that moves when the buffered webhook throws events away, because the SIEM is slow or simply gone, is apiserver_audit_error_total, labeled by the plugin that lost them. That is the deal you made when you chose batch mode: a dead SIEM never slows a single kubectl command, so nothing looks wrong from the cluster side until you look here. And the only end-to-end proof is the round trip. Run one exec you'll recognize, then go and find it in the SIEM and time how long that takes. If the answer is hours, or it never arrives, the control won't help you during an incident even though every byte is sitting on the node.
From alert to answer
Here's where it pays off on a bad day. Falco fires: unexpected shell in payments-api. You pivot to the audit log, scope it to that namespace and the few minutes around the alert, and ask two questions. Which RBAC (Role-Based Access Control) identity ran the exec? And what did that same identity touch right after? Same identity, three namespaces, three seconds apart reads as automated credential sweeping, not a human. That's the jump from 'something happened somewhere' to a named subject and a blast radius you can actually measure: revoke that one service account and rotate exactly the Secrets it reached, instead of guessing.
$ jq -c 'select(.objectRef.namespace=="payments" and .verb=="create"and .objectRef.subresource=="exec")| {who:.user.username, pod:.objectRef.name, when:.requestReceivedTimestamp}' \/var/log/kubernetes/audit.log{"who":"system:serviceaccount:ci:deployer","pod":"payments-api-7d9","when":"2026-07-16T15:02:11Z"}$ jq -c 'select(.user.username=="system:serviceaccount:ci:deployer"and .objectRef.resource=="secrets" and .verb=="list")| {ns:.objectRef.namespace, when:.requestReceivedTimestamp}' \/var/log/kubernetes/audit.log{"ns":"payments","when":"2026-07-16T15:02:40Z"}{"ns":"billing","when":"2026-07-16T15:02:41Z"}{"ns":"identity","when":"2026-07-16T15:02:43Z"}
Storage is part of the control
Here is the part that gets skipped once the flags pass. The audit log is now a data store, and a data store needs an owner, a lifetime, and a lock. The file on the node is root-only for a reason, and the bucket or index it lands in deserves the same care, because an audit archive every engineer can read is a searchable map of which identity reached which Secret in which namespace, handed over to whoever phishes one laptop. So decide who can read it and how long you keep it at the same time you decide what to record, and keep the verification query and its expected output in the same pull request as the policy, so the next person on call can tell pass from fail without inventing a jq filter at three in the morning.
Try this
Enable a focused audit policy on a lab API server, exec into a pod, and find the matching audit event by user and resource. Watch which identity turns up: it's whoever your kubeconfig says you are, not the pod's service account. And if the grep comes back empty, that's the lesson rather than a broken cluster. A request that matches no rule at all is written nowhere, so a policy with a gap in it and a policy that was never loaded look identical from the outside.
$ sudo cat /etc/kubernetes/audit/policy.yamlapiVersion: audit.k8s.io/v1kind: Policyrules:- level: Metadataresources: [{ group: "", resources: ["secrets"] }]- level: Metadata # your exec matches here, whoever you areresources: [{ group: "", resources: ["pods/exec"] }]$ kubectl -n payments exec deploy/payments-api -- iduid=10001 gid=10001 groups=10001$ sudo grep '"subresource":"exec"' /var/log/kubernetes/audit.log \| tail -1 | jq -c '{level, who:.user.username, pod:.objectRef.name}'{"level":"Metadata","who":"kubernetes-admin","pod":"payments-api-7d9f8c6b4-2xk9p"}
Takeaway
API audit logs answer who asked the control plane to do what. Pair them with runtime alerts so exec and secret reads become a timeline.
level: Metadata catch-all rule, and below it sits a level: None rule meant to drop kube-proxy's noisy watch traffic on endpoints and services. What actually happens to that watch traffic?level: Metadata rather than Request or RequestResponse. Why?kube-bench reports [PASS] on every --audit-log flag. A teammate concludes the cluster is now capturing the security-relevant events. Are they right?