Build a small workload API that identifies a process by asking the kernel who it is, then hands it a certificate valid for one minute — no password, no bootstrap secret, nothing on disk.
Learning objectives
Explain how a short-lived credential reduces, but does not remove, the need for revocation.
Describe the secret zero problem and how attestation avoids it.
Read a SPIFFE ID and say what an SVID is.
Build a workload API that attests a caller with SO_PEERCRED and issues a sixty-second certificate.
The problem you have been carrying since Lesson 5
Look at the certificate you generated:
zsh — 80×24
$openssl x509 -in certs/go.crt -noout -dates
$
zsh — 80×24
$notBefore=Aug 1 11:00:00 2026 GMT
$notAfter=Jul 30 11:00:00 2036 GMT
$
Ten years. Sitting in a folder. Which means:
if it leaks, it stays valid until 2036 and nobody will notice;
cancelling it early needs extra infrastructure — a revocation list or an OCSP responder — that almost nobody actually runs correctly;
replacing it is a manual job, so it gets postponed forever.
The instinct is "guard the file better". That is the wrong instinct, because it accepts the ten years as fixed and tries to compensate.
The actual fix
The idea of this chapter
Make certificates expire before they can matter.
A certificate that lives sixty seconds mostly cancels itself. Steal one and you have under a minute to open a connection with it. Leak one into a log and it is useless before anyone reads the log.
That removes the operational need for a revocation system in the common case, which is a large practical win. Be careful with the stronger version of the claim, though, because two things survive expiry:
sixty seconds is plenty of time for a high-impact operation, if the attacker knows what they want;
a connection already authenticated can outlive the certificate that authenticated it, which Lesson 13 demonstrates and which is the part people miss.
So the honest statement is that short lifetimes make revocation unnecessary for most purposes and cheap to skip, not that revocation is meaningless. You still want a way to remove a trust anchor and to stop issuing to a workload, and you still need to bound connection age.
But sixty-second certificates only work if something issues and replaces them automatically. Nobody runs gen-certs.sh every minute. That something is what you build in this lesson.
Platform requirement
The workload API in this chapter uses SO_PEERCRED, a Linux socket option, and reads /proc. Lessons 11 to 14 therefore need Linux or WSL. Everything up to Lesson 10 runs anywhere. macOS has an equivalent (LOCAL_PEERCRED / getpeereid) that reports the peer's user id but not its process id, so the command-line attestation below would have to be replaced.
SPIFFE in two ideas
SPIFFE is a standard for giving programs an identity. SPIRE is its best-known implementation. You are about to build a very small stand-in for the SPIRE agent.
Idea 1: an identity is a URI, not a hostname
a spiffe id
spiffe://demo.local/service/go
▲ ▲ ▲
scheme trust domain name you choose
Notice it says nothing about where the service runs. That is deliberate. Containers move, scale up, and get rescheduled onto other machines. Identity should not change just because an IP address did.
This is also why subjectAltName=DNS:localhost,IP:127.0.0.1 from Lesson 5 was always a slightly awkward fit: it identified a location, not a program.
Idea 2: a certificate carrying that URI is called an X.509-SVID
SVID stands for SPIFFE Verifiable Identity Document. Nobody says the long version.
SPIFFE defines two kinds. An X.509-SVID is a certificate, and that is the one this course builds. A JWT-SVID carries the same identity as a signed token instead, for places where you cannot use client certificates. When this course says SVID it means the X.509 kind.
Here is the reassuring part: an SVID is not a new kind of file. It is an ordinary X.509 certificate, exactly like the ones in Lesson 5, with the SPIFFE URI stored in the same subjectAltName extension you already met — as a URI: entry instead of a DNS: entry.
Compared with your Lesson 5 certificate
SVID
File format
Identical. Same X.509, same PEM
Signed by a CA
Yes, same as before
Proves possession by signing the handshake
Yes, same as before
Contains a name
Yes — a URI instead of a common name
Contains a hostname or IP
Not the ones we issue. This is the one real difference
Lifetime
60 seconds instead of 10 years
Stored on disk
No. Held in memory only
Only the last three rows are new. Everything you learned about certificates still applies.
The puzzle SPIFFE really solves: secret zero
Think carefully about this loop:
to get a certificate, you must prove who you are;
to prove who you are, you need a credential;
that credential must also be delivered somehow, protected by another credential.
It is turtles all the way down. The first secret in the chain is called secret zero. It is where a large number of real breaches begin, because it ends up in an environment variable or a configuration file that far too many people can read.
SPIFFE breaks the loop by asking the operating system instead.
Flow· Getting a credential with no credential
your service workload API (the agent)
│ │
│─── connects to a unix socket ──────►│
│ sends NOTHING: │
│ no key, no password, no token │
│ ├─ asks the KERNEL:
│ │ "which process is this?"
│ │ → pid 12345, uid 1000
│ │
│ ├─ looks up pid 12345:
│ │ what is it running?
│ │
│◄── certificate + private key ───────┤─ matches a registration entry
│ + trust bundle, valid 60s │ → spiffe://demo.local/service/go
The workload sends nothing and still receives a credential. It is identified by what it is, not by a secret it holds. There is no first secret, so there is nothing to leak.
In simple words
A password is a guard asking "what is the secret word?", which anyone who overheard it can answer. Attestation is a guard who does not ask anything, looks at which door you came through and what uniform you are wearing, and checks that against a list. You cannot overhear your way into being someone else.
Three words you will meet everywhere
Word
Means
Attestation
Working out what a process actually is
Selector
A rule describing a process, such as unix:uid:1000 or k8s:sa:orders
Registration entry
A mapping from selectors to a SPIFFE ID
Build the workload API
zsh — 80×24
$mkdir workload-api
$
Create workload-api/main.go. This is a third package main, run with go run ./workload-api, exactly like the auth server.
Nothing else in your repository changes in this lesson. Your services keep using certs/ until Lesson 13.
Nothing is written to disk. When this process stops, the CA is gone and every certificate it issued expires within a minute. Compare that with ca.key, the most dangerous file in your repository.
The repeated argument.template is passed twice, because the CA signs its own certificate: the same object is both the certificate being created and the issuer signing it. That is what "self-signed root" means, and you saw its effect in Lesson 5 when subject and issuer matched.
SO_PEERCRED is the important part. It asks the operating system: which process is on the other end of this socket? The OS answers with a process id and a user id.
Why the process cannot lie
The pid and uid come from the kernel's own record of who opened the socket, not from anything the connecting program sent. There is no field in the message to forge, because there is no message.
Then /proc/<pid>/cmdline tells us what that process is running.
A unix socket is a connection identified by a path on the filesystem rather than by a host and port. Programs read and write it exactly like a network connection, but only processes on the same machine can open it.
Why a unix socket and not a TCP port?
SO_PEERCRED only exists for local sockets. Over TCP there is no "which process", because the connection could come from any machine anywhere. Being local is exactly what makes attestation possible.
The registration table
workload-api/main.goGo
type entry struct { cmdlineContains string spiffeID string}// Order matters: the first match wins. The node process's command line can// contain BOTH "server.ts" and the project folder name, so "server.ts" has to// be checked first.var entries = []entry{ {"server.ts", "spiffe://" + trustDomain + "/service/typescript"}, {"authserver", "spiffe://" + trustDomain + "/service/auth"}, {"secure-services", "spiffe://" + trustDomain + "/service/go"},}
Those three strings come from how each program is started:
Command
Command line the kernel reports contains
node server.ts
server.ts
go run ./authserver
authserver
go run .
secure-services, the folder name
go run compiles to a temporary binary named after the package directory, which is why the third entry matches your project folder.
How real SPIRE differs
Matching one substring of a command line is weak: any process whose command line happens to contain authserver would be issued that identity. Real SPIRE requires a set of selectors to match at once — user id, binary path, Kubernetes service account, container label — which removes both the weakness and the ordering problem. We use one readable rule so the mechanism stays visible.
Look hard at what is missing: no Subject, no DNSNames, no IPAddresses.
A SPIFFE certificate identifies a workload, not a host. That will break normal TLS verification, which checks the hostname, and Lesson 12 deals with it.
Also note the fresh key pair on every issue. Each issued leaf is valid for sixty seconds; when the services add rotation in Lesson 13, they request a new leaf and private key every twenty seconds so they renew well before expiry. The workload API's CA key is longer-lived, and the old service keys still remain under certs/ until Lesson 13; the claim here is specifically that the newly issued leaf credential is short-lived and replaced rather than reused.
The NotBefore is thirty seconds in the past. Clocks between machines are never perfectly aligned, and a certificate that is not valid yet fails just as hard as one that has expired.
A process that matches nothing gets no answer and no explanation. That is the correct behavior: the caller learns nothing about which identities exist.
The complete workload API
workload-api/main.goGo
// A tiny stand-in for the SPIRE agent.//// Real SPIRE is a pair of daemons and a gRPC API. This does the same four// things in a form you can read in one sitting://// 1. It holds the CA. Nothing else has a key on disk.// 2. It listens on a UNIX SOCKET, not a TCP port.// 3. When a workload connects, it asks the KERNEL who that process is.// The workload does not get to say. This is called ATTESTATION.// 4. It issues a certificate that lives 60 seconds, named with a SPIFFE ID.//// Linux only: SO_PEERCRED and /proc are Linux features.package mainimport ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "log" "math/big" "net" "net/url" "os" "strings" "syscall" "time")const ( socketPath = "/tmp/spire-lite.sock" trustDomain = "demo.local" // Sixty seconds. Long enough to be usable, short enough that you can watch // it rotate while reading the logs. svidTTL = 60 * time.Second)// A registration entry maps "a process that looks like THIS" to a SPIFFE ID.type entry struct { cmdlineContains string spiffeID string}// Order matters: the first match wins. The node process's command line can// contain BOTH "server.ts" and the project folder name, so "server.ts" has to// be checked first.var entries = []entry{ {"server.ts", "spiffe://" + trustDomain + "/service/typescript"}, {"authserver", "spiffe://" + trustDomain + "/service/auth"}, {"secure-services", "spiffe://" + trustDomain + "/service/go"},}type svid struct { SpiffeID string `json:"spiffe_id"` Cert string `json:"cert"` Key string `json:"key"` Bundle string `json:"bundle"` TTLSeconds int `json:"ttl_seconds"`}type ca struct { key *ecdsa.PrivateKey cert *x509.Certificate bundle string // the CA certificate as PEM, for workloads to trust}// newCA creates the trust root in memory, at startup.func newCA() (*ca, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } template := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "workload-api-ca"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, IsCA: true, } der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { return nil, err } cert, err := x509.ParseCertificate(der) if err != nil { return nil, err } return &ca{ key: key, cert: cert, bundle: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), }, nil}// issue mints one short-lived SVID for the given SPIFFE ID.//// Look at what is NOT in this certificate: no CommonName, no DNS name, no IP// address. A SPIFFE certificate identifies a WORKLOAD, not a host.func (c *ca) issue(spiffeID string) (*svid, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } uri, err := url.Parse(spiffeID) if err != nil { return nil, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, err } template := &x509.Certificate{ SerialNumber: serial, NotBefore: time.Now().Add(-30 * time.Second), // allowance for clock skew NotAfter: time.Now().Add(svidTTL), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{ x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth, }, URIs: []*url.URL{uri}, // <- the identity lives here BasicConstraintsValid: true, } der, err := x509.CreateCertificate(rand.Reader, template, c.cert, &key.PublicKey, c.key) if err != nil { return nil, err } keyDER, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { return nil, err } return &svid{ SpiffeID: spiffeID, Cert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), Key: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})), Bundle: c.bundle, TTLSeconds: int(svidTTL.Seconds()), }, nil}// attest asks the kernel who is on the other end of this socket.//// SO_PEERCRED is the important bit. The operating system reports the connecting// process's pid and uid, and a process cannot lie about them. We then read that// pid's command line from /proc to decide which registration entry applies.func attest(conn *net.UnixConn) (pid int32, uid uint32, cmdline string, err error) { raw, err := conn.SyscallConn() if err != nil { return 0, 0, "", err } var creds *syscall.Ucred var credErr error if err := raw.Control(func(fd uintptr) { creds, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) }); err != nil { return 0, 0, "", err } if credErr != nil { return 0, 0, "", credErr } data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", creds.Pid)) if err != nil { return creds.Pid, creds.Uid, "", err } // /proc stores arguments separated by NUL bytes. return creds.Pid, creds.Uid, strings.ReplaceAll(string(data), "\x00", " "), nil}func (c *ca) handle(conn *net.UnixConn) { defer conn.Close() pid, uid, cmdline, err := attest(conn) if err != nil { log.Printf(" attestation failed: %v", err) return } for _, e := range entries { if !strings.Contains(cmdline, e.cmdlineContains) { continue } s, err := c.issue(e.spiffeID) if err != nil { log.Printf(" issue failed: %v", err) return } log.Printf(" issued %-38s pid=%d uid=%d ttl=%s", s.SpiffeID, pid, uid, svidTTL) _ = json.NewEncoder(conn).Encode(s) return } log.Printf(" no registration entry matches pid=%d (%s)", pid, strings.TrimSpace(cmdline))}func main() { log.SetFlags(log.Ltime) c, err := newCA() if err != nil { log.Fatalf("could not create CA: %v", err) } // A leftover socket file from a previous run would block us. os.Remove(socketPath) listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) if err != nil { log.Fatalf("could not listen on %s: %v", socketPath, err) } defer os.Remove(socketPath) fmt.Printf("The Workload API is running on %s (issues %s SVIDs)\n", socketPath, svidTTL) for _, e := range entries { fmt.Printf(" registered: cmdline contains %-18q -> %s\n", e.cmdlineContains, e.spiffeID) } for { conn, err := listener.AcceptUnix() if err != nil { log.Printf(" accept failed: %v", err) continue } go c.handle(conn) }}
Test it right now
zsh — 80×24
$go run ./workload-api
$
zsh — 80×24
$The Workload API is running on /tmp/spire-lite.sock (issues 1m0s SVIDs)
no registration entry matches pid=569950 (nc -U /tmp/spire-lite.sock)
That is attestation refusing you. Read what just happened carefully: you had no secret to steal, and knowing the socket path did not help. There was nothing to guess.
Press Ctrl+C to stop nc, and leave the workload API running for Lesson 12.
What you have and have not built
Claim
True?
Issues certificates with no credential from the caller
Yes
Identity survives a process moving to another machine
Yes, the ID has no address in it
Workload API writes no long-lived credential to disk
Yes, its CA is in memory; the old certs/ directory remains until Lesson 13
Safe as a production attestation policy
No — one command-line substring is too weak
Highly available
No — it is one process, and Lesson 14 discusses the cost
Common errors
Error
Meaning
Fix
undefined: syscall.GetsockoptUcred
You are not on Linux
Use WSL, a Linux VM, or a container
bind: address already in use on the socket
A stale socket file
The code calls os.Remove(socketPath) at startup; check nothing else is running
no registration entry matches for your own service
The process name is not in the table
Start it exactly as documented, or adjust the entry to your folder name
permission denied connecting to the socket
The socket belongs to another user
Run both as the same user
Lesson 11 checkpoint
You are ready for Lesson 12 when you can explain all of these without rereading:
why a sixty-second certificate removes the everyday need for a revocation list, and what emergency controls it does not replace;
what secret zero is, and how attestation removes it;
why a process cannot lie about its pid to the workload API;
why attestation requires a unix socket rather than a TCP port;
what is deliberately missing from an SVID compared with the certificates in Lesson 5;
why the registration table in this demo would be unsafe in production.
Next you will write the small library that knows how to talk to this agent, before rewiring anything to use it.