Decode Kubernetes Secrets, then mount one read-only (CKS)
Welcome to lesson one of the CKS series. We start with Secrets, because they show up in nearly every version of the exam, and because the first thing a security engineer should learn about them is how easy they are to read. Two parts today: pull values out of existing Secrets and write them to a file, then create a new Secret and mount it into a pod read-only. Let's run it on a live cluster.
This is a CKS Minimize Microservice Vulnerabilities walkthrough. Every command below is real output from a live cluster, and you can reproduce the whole thing yourself (scripts at the end).
The scenario
Here is the setup. The finance namespace holds two Secrets, vault-seed and ops-login, and each has more than one key. You want the token key from vault-seed and the user key from ops-login, decoded to plain text and written to a file called decoded.txt, one value per line, token first. Then, in the billing namespace, create a Secret called billing-api holding a username and a password, and run a pod called invoice-runner that mounts it read-only at /etc/billing.
- 1. ns finance: decode 'token' of vault-seed and 'user' of ops-login
- Write both to decoded.txt, one value per line, token first
- 2. ns billing: create Secret billing-api (username + password)
- Pod invoice-runner (busybox:1.36) mounts it READ-ONLY at /etc/billing
Why base64 is not encryption
One idea to have straight before typing. The values under data in a Secret are base64 encoded, and base64 is an encoding, not encryption. There is no key. Anyone who can run get on that Secret can read it in one line, which is why CKS spends so much time on RBAC and encryption at rest. The second idea is newlines. base64 -d prints the value with no newline after it, so when you write several values to a file you add the newline yourself. And when you encode by hand, use echo -n, because a plain echo slips a newline into the value and the base64 comes out different.
What you are handed
Start by looking. List the Secrets in finance: two Opaque Secrets, each with two data entries, so you have to pick the right key rather than decode everything. Then look at vault-seed in YAML. Under data you can see both keys, token and endpoint, and their values are not readable yet. They look scrambled, but that is just base64.
$ kubectl get secrets -n finance
NAME TYPE DATA AGE
ops-login Opaque 2 0s
vault-seed Opaque 2 0s
$ kubectl -n finance get secret vault-seed -o yaml | grep -A2 '^data:'
data:
endpoint: aHR0cHM6Ly92YXVsdC5maW5hbmNlLnN2Yzo4MjAw
token: cjNkLWw0bnQzcm4tOTA0MQ==
Decode each key
Now decode. Ask kubectl for just the one key with jsonpath, pipe it into base64 -d, and the plain value appears. The trailing echo only moves the prompt onto a new line so you can read it. Same move for the user key of ops-login. And here is the newline trap, shown for real. Encode the value back with echo -n and you get the exact string stored in the Secret. Drop the dash n and the base64 changes, because the newline is now part of the value. If you ever build a Secret by hand in the exam, that one flag is the difference between a correct and a broken credential.
$ kubectl -n finance get secret vault-seed -o jsonpath='{.data.token}' | base64 -d; echo
r3d-l4nt3rn-9041
$ kubectl -n finance get secret ops-login -o jsonpath='{.data.user}' | base64 -d; echo
sre-oncall
$ echo -n sre-oncall | base64
c3JlLW9uY2FsbA==
$ echo sre-oncall | base64
c3JlLW9uY2FsbAo=
Write the answer file
Now the answer file. Group both commands in braces with an echo after each decode, and redirect the whole group into decoded.txt. Doing it in one go means no copy and paste, and no typo in a value you cannot see properly. Then check it with cat -A, which marks every line end with a dollar sign. Two lines, the token first, each ending cleanly. That is exactly the format the grader compares against.
$ { kubectl -n finance get secret vault-seed -o jsonpath='{.data.token}' | base64 -d; echo; kubectl -n finance get secret ops-login -o jsonpath='{.data.user}' | base64 -d; echo; } > decoded.txt
$ cat -A decoded.txt
r3d-l4nt3rn-9041$
sre-oncall$
Create the new Secret
Part two. Create the Secret imperatively, in the billing namespace, with one --from-literal flag per key. kubectl does the base64 for you, so there is no echo, no newline to worry about, and nothing to encode by hand. This is the fastest correct way in the exam; only reach for YAML if the task hands you one.
$ kubectl -n billing create secret generic billing-api --from-literal=username=svc-invoice --from-literal=password=Qz7-ledger-2026
secret/billing-api created
Mount it read-only
Now the pod. Generate a skeleton with kubectl run and dry-run, then add two blocks. Under volumes, a volume of type secret that names billing-api. Under the container, a volumeMount with that volume's name, the mount path /etc/billing, and readOnly: true. That last line is the one the task is really about. Apply it and wait for the pod to be Ready.
$ cat invoice-runner.yaml
...
terminationGracePeriodSeconds: 5
containers:
- name: runner
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
volumeMounts:
- name: api-creds
mountPath: /etc/billing
readOnly: true
volumes:
- name: api-creds
secret:
secretName: billing-api
$ kubectl apply -f invoice-runner.yaml && kubectl -n billing wait --for=condition=Ready pod/invoice-runner
pod/invoice-runner created
pod/invoice-runner condition met
Prove it
Prove both halves. Inside the pod, /etc/billing holds one file per key, and reading username gives the plain value: the kubelet decodes it for the container. Now try to write there, and the file system refuses: read-only. To be honest about what you are seeing, the kubelet mounts secret volumes read-only anyway. But the grader checks the pod spec, not your intentions, so the last check reads the volumeMount itself, and it says readOnly: true.
$ kubectl -n billing exec invoice-runner -- ls /etc/billing
password
username
$ kubectl -n billing exec invoice-runner -- cat /etc/billing/username
svc-invoice
$ kubectl -n billing exec invoice-runner -- touch /etc/billing/tamper
touch: /etc/billing/tamper: Read-only file system
command terminated with exit code 1
$ kubectl -n billing get pod invoice-runner -o jsonpath='{.spec.containers[0].volumeMounts[0]}'
{"mountPath":"/etc/billing","name":"api-creds","readOnly":true}
Exam tips
Things to carry into the exam. Always pass the namespace; both Secrets here live outside default. Decode a single key with jsonpath instead of eyeballing the YAML, and remember the dot in a key name needs a backslash in jsonpath. Write the file in one redirect, then confirm with cat -A. If you encode by hand, echo -n, every time. Prefer --from-literal for new Secrets. And set readOnly: true on the mount explicitly, even though the kubelet would do it for you. If the file path is on a different host in the exam, remember to go back to the base terminal before you check your work.
- Always -n ; neither Secret is in default
- jsonpath '{.data.}' | base64 -d; escape dots in key names
- One redirect for the file, then cat -A to check the line ends
- echo -n when encoding by hand
- kubectl create secret generic --from-literal: no manual base64
- Set readOnly: true on the mount explicitly
Recap
- Secret data is base64: encoding, not encryption
- jsonpath + base64 -d; add a newline per value; check with cat -A
- echo -n or the newline becomes part of the value
- --from-literal Secret, mounted readOnly: true; subscribe + dev.to writeup
Reproduce this yourself
The entire scenario is scripted on a throwaway kind cluster: https://github.com/The-Cyber-Sidekick/TCS_CKS_2026_Exam_Scenarios
git clone https://github.com/The-Cyber-Sidekick/TCS_CKS_2026_Exam_Scenarios.git
cd TCS_CKS_2026_Exam_Scenarios/scenario1-secret-decode
./setup.sh # creates the cluster AND arms the scenario
# solve it by hand, or:
./solution.sh # apply the answer key and verify
If this helped, subscribe to The Cyber SideKick on YouTube for more CKS drills, and grab the newsletter at https://thecybersidekick.beehiiv.com.
Top comments (0)