Lab 12 — Workload Identity & mTLS with SPIFFE/SPIRE¶
Hands-on lab. Environment:
plaintext-labs/ztna/10-workload-identity-mtls. Objective: issue each workload a short-lived SPIFFE identity, prove service-to-service mTLS keyed on it, and prove an unregistered workload gets nothing. Target: ~90 min, one finish line. This is a reference lab — a one-command Docker environment (SPIRE server + agent + two workloads).
✈ Flight card — the 6 things to hold¶
Glance here when you lose the thread. This replaces re-reading the module.
| # | Fact | Why it matters |
|---|---|---|
| 1 | Identity is a credential you PROVE (an SVID), not a secret you HOLD. | A held key can be stolen and reused (Capital One); a fetched, short-lived SVID can't. |
| 2 | Two-layer attestation: the agent proves the node, then attests each workload by selectors it can't forge. | This is how a brand-new workload gets its first credential without being pre-trusted. |
| 3 | mTLS keyed on identity: both ends present an SVID and validate the peer's SPIFFE ID. | Verify return code: 0 means both sides were authenticated by identity, not IP. |
| 4 | Short TTL + auto-rotation (300s here). | Nothing long-lived sits in the image; a leaked SVID expires in minutes. |
| 5 | The selector is the whole security decision. | A loose selector (a shared UID, a label anything can set) = a wildcard IAM grant. |
| 6 | Authentication, not authorization. | SPIFFE proves who is calling; a policy engine (OPA module) decides whether — identity here, decision there. |
(The claim you're proving: identity, not network position, grants access — rogue sits on the
same network as client and still gets nothing.)
↳ Go deeper — pull only when a step doesn't click: the module's core idea and the two-layer attestation and handshake sections.
Warm-up — answer before you bring the lab up (2 min)¶
Don't look below. Being forced to retrieve is what builds the memory.
- Two services sit on the same network segment. A label-based allow rule lets them connect. What does that rule not force the caller to do that workload identity does?
- A container just started and holds no credential yet. How can SPIRE hand it its first SVID without you having already trusted it — what is the agent inspecting to decide?
Setup¶
The environment lives in the companion plaintext-labs repo — one locally-built image runs a SPIRE
server, a one-shot bootstrap, an agent, and two workloads. No cloud account required.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/ztna/10-workload-identity-mtls
make up # build + start SPIRE (server, agent), bootstrap entries, start the workloads
make mtls # client fetches its SVID and makes a mutual-TLS call to the backend
make deny # prove an unregistered workload is refused an identity
make check # assert: registered workloads get the right SVID, rogue gets none
make demo # the full walkthrough (mTLS success, then the deny)
make shell # drop into the client workload (spire-agent CLI + openssl)
make down # stop when done
▸ On track if:
make upfinishes withLab ready. Try: make mtls | make deny | make demo. The agent uses the Docker workload attestor, so it needs the host's Docker socket and PID namespace (already wired indocker-compose.yml) to map a calling workload back to its container labels.Authorization note. Everything runs locally against containers you own — no external targets, so no authorization is needed here. (The rule still binds anywhere you point a tool at a system you don't own: only test what you own or have explicit written permission to test.)
Build it — read a little, do a little¶
Step 1 — Bring it up and read what got registered¶
Concept (30 sec): Flight-card #2. A registration entry maps a selector the agent can observe
(docker:label:com.corp.svc:<name>) to a SPIFFE ID (spiffe://corp.local/<name>). Two workloads are
registered on purpose; rogue is deliberately left out.
Do it: make up, then read the entries the server issued:
docker compose exec spire-server spire-server entry show -socketPath /tmp/spire-server/private/api.sock
data/setup.sh — note it registers backend and client and
nothing for rogue.
▸ On track if: you see two entries, each pairing a selector
docker:label:com.corp.svc:backend/…:clienttospiffe://corp.local/backend/…/client. No entry mentionsrogue.
Step 2 — Watch a workload fetch its own identity¶
Concept (30 sec): Flight-card #1 + #4. The workload holds no baked-in key — it fetches its SVID from the Workload API at runtime, with a short TTL, and the agent re-issues it before it expires.
Do it: make shell (drops you into client), then:
▸ On track if: the output shows
SPIFFE ID: spiffe://corp.local/clientand a TTL on the order of 300 seconds. A second fetch minutes later shows a fresh certificate — nothing long-lived to steal.
Step 3 — Prove identity with mutual TLS¶
Concept (30 sec): Flight-card #3. In an mTLS handshake both ends present an SVID and validate the peer against the same trust bundle — the trust decision is the peer's SPIFFE ID, not its IP.
Do it: make mtls (or run /opt/spire/workload/connect.sh from the client shell). The client
fetches its SVID and connects to the backend on :8443. Read workload/backend.sh — which
openssl s_server flag is what requires the client to present a valid cert? (Answer: -Verify 1.)
▸ On track if: the output contains
hello from spiffe://corp.local/backend,Verify return code: 0, and✅ mutual TLS succeeded — both ends presented a valid SVID.Both sides were authenticated by identity.
Step 4 — Prove the boundary: no identity, no access¶
Concept (30 sec): The core claim. rogue carries a label (com.corp.svc=rogue) that matches no
registration entry, so the Workload API refuses it an SVID — even though it's on the same network
as client.
Do it: make deny. Watch rogue ask the Workload API for an SVID and get refused; with no cert to
present, it cannot complete an mTLS handshake to the backend.
▸ On track if:
rogue's fetch returns no SVID (a "no identity issued" / refused result), and you can articulate: same network asclient, different outcome — because identity, not position, grants access. Ifroguesomehow got an identity, a selector is too loose — recheckdata/setup.sh.
Step 5 — Tighten a selector (the judgment step)¶
Concept (30 sec): Flight-card #5. The lab attests on a Docker label, which any deployment could set. In a real environment you pin to something harder to forge.
Do it: in your write-up, name a stronger selector (an image digest, a Kubernetes service
account bound to a namespace) and explain why a loose selector is the workload-identity equivalent of
a wildcard IAM grant. Optionally edit data/setup.sh to add an image-based selector to one entry and
re-verify it still issues.
▸ On track if: your write-up answers "could a different workload satisfy this selector?" for the label case and proposes a selector where the answer is no.
Prove the control (your finish line)¶
Run the one assertion that proves the whole control at once — identified-allowed vs. unidentified-denied:
The proof: check_identity.sh asserts that backend receives exactly spiffe://corp.local/backend,
client receives exactly spiffe://corp.local/client, and rogue receives nothing.
▸ On track if: you see
PASS backend -> spiffe://corp.local/backend,PASS client -> …/client,PASS rogue received no SVID, and a finalN passed, 0 failed. That single run is the control: two workloads on the same network, and only the ones with a provable identity get in.
Recall check — close the docs, answer from memory (3 min)¶
- What does an mTLS
Verify return code: 0prove that a network-segment allow rule never can? - Two layers of attestation — what does the agent prove to the server, and what does it then attest about each workload? Why can't the workload forge the second?
- SPIFFE proves which workload is calling — so what is it not doing, and where does that decision belong instead?
Missed one? Re-run the step that built it, or pull the core idea — then re-answer.
Deliverables¶
findings.md— the workload-identity write-up: the two registration entries, the runtime SVID fetch (with TTL), the mTLS proof (both ends verified,Verify return code: 0), the unregistered-workload deny, and the selector-strength analysis from step 5. A portfolio artifact: it shows you can issue provable workload identities, key mTLS on them, and demonstrate the deny.
Never commit SVIDs, private keys, or the join token — they're in .gitignore.
Automate & own it¶
Required. Replace the openssl plumbing with a real SPIFFE-aware mTLS client: write a small Go
program using go-spiffe/v2 (or Python with pyspiffe) that
connects to the backend off the Workload API, authorizes the peer by SPIFFE ID (accept only
spiffe://corp.local/backend), and fails closed on any other identity. Have a model draft the
workloadapi + tlsconfig calls; you verify it rejects a peer whose SPIFFE ID doesn't match —
authenticating the channel is worthless if you don't check who is on the other end. Commit it as
mtls-client/.
Definition of done (workload-identity ✅)¶
- [ ]
make upissues SVIDs;spire-server entry showlists exactly thebackendandcliententries (none forrogue). - [ ] A runtime SVID fetch from
clientshowsspiffe://corp.local/clientwith a short (~300s) TTL, and re-fetches show rotation. - [ ]
make mtlscompletes withhello from spiffe://corp.local/backend,Verify return code: 0, and the success line. - [ ]
make denyshows the unregisteredrogueis refused an SVID and cannot connect. - [ ]
make checkendsN passed, 0 failed— identified-allowed and unidentified-denied both proven. - [ ]
findings.md+mtls-client/are committed; you can explain all six flight-card facts cold.
Connects forward¶
This is the service-to-service counterpart to the human-facing modules: a SVID is to a workload what a
short-lived OIDC token is to a user, and what a device key is to a laptop. SPIFFE authenticates the
workload; the OPA module authorizes it — feed the peer's SPIFFE ID into a Rego policy to decide
whether …/web may call …/ledger. And the SVID-issuance + mTLS-handshake events are exactly the
identity-rich telemetry the detection module hunts against.
Marketable proof¶
"I stand up a SPIFFE/SPIRE trust domain, issue short-lived auto-rotating workload identities, and enforce mutual TLS between services keyed on SPIFFE ID — so service-to-service access is granted by provable identity, not network position, and an unregistered workload gets nothing."
Stretch¶
- Add a third workload (
ledger) and write a go-spiffe server that authorizes onlyspiffe://corp.local/backendto call it — then proveclientis rejected even though it holds a valid SVID (authentication succeeds, authorization denies). That's the SPIFFE→OPA handoff in miniature. - Shorten
default_x509_svid_ttl(or the entry's-x509SVIDTTL) to1mand watch the agent rotate the SVID under a live mTLS connection; confirm long-lived connections survive rotation while new ones get the fresh cert. - Swap the Docker attestor for the unix (UID-based) attestor and construct a workload that satisfies that selector without being the intended service — showing concretely why the loose selector is the wildcard-grant failure mode.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).