Zero Trust for Ephemeral CI Runner Attestation with OIDC and SPIFFE-SAN
Written by
Vera Crypt
The problem I kept hitting
I wanted a “Zero Trust”-style way to ensure that only my continuously created CI runners could reach sensitive internal services—and that the access would be automatically revoked when the runner disappeared. The catch: CI pipelines are ephemeral, and traditional IP allowlists or long-lived API keys are basically the opposite of what Zero Trust is trying to prevent.
So I built a small, specific pattern: ephemeral CI runner attestation using OIDC (OpenID Connect) tokens mapped into a SPIFFE-like identity expressed as a SAN (Subject Alternative Name). Then I wired that identity into an authorization decision on the service side.
OIDC is a standardized way for an app (the runner) to prove who it is using a short-lived token.
SPIFFE is a way to represent workload identity as a URI-like string.
SAN is the X.509 certificate extension that can carry identities for verification.
The result: the service only accepts connections if:
- the CI runner presents a valid OIDC token, and
- the service verifies that token maps to an expected SPIFFE-like identity, and
- the service issues/accepts a certificate identity that matches a SAN field.
What I built (niche but useful)
A minimal “attestation proxy + mTLS service gate”:
- The CI runner obtains an OIDC token from the CI platform (conceptually: GitHub Actions OIDC, GitLab OIDC, etc.).
- The attestation proxy validates the token (signature + claims).
- The proxy derives an identity like:
spiffe://example-ci/workload/<repo>/<workflow>/<run_id>
- The proxy then issues a short-lived certificate for that identity.
- The protected service requires mTLS and checks that the peer certificate contains the SAN identity that matches the expected SPIFFE URI.
This is Zero Trust in practice: identity-first, short-lived credentials, continuous verification at connection time.
Step 1: Attestation proxy (validate OIDC, mint identity SAN)
Below is a working Node.js example using:
joseto validate the OIDC JWT (JSON Web Token)cryptoto create a simple proof-of-identity certificateexpressto expose an endpoint the CI runner can call
Install
npm init -y npm i express jose jsonwebtoken npm i -D nodemon
Proxy server
// server.js import express from "express"; import crypto from "crypto"; import { createRemoteJWKSet, jwtVerify } from "jose"; const app = express(); app.use(express.json()); /** * Configuration knobs you would set in real life: * - OIDC_ISSUER: token issuer URL from your CI provider * - JWKS_URL: where to fetch the signing keys (JSON Web Key Set) * - EXPECTED_AUD: the audience your service expects in the token */ const OIDC_ISSUER = process.env.OIDC_ISSUER; // e.g. "https://token.actions.githubusercontent.com" const JWKS_URL = process.env.JWKS_URL; // e.g. "https://token.actions.githubusercontent.com/.well-known/jwks" const EXPECTED_AUD = process.env.EXPECTED_AUD; // e.g. "sts.amazonaws.com" or your service audience // In this demo, we expect workflow identities in a specific shape. function spiffeFromClaims(claims) { const repo = claims.repository; // example claim name; varies by CI provider const workflow = claims.workflow; // example claim name; varies by CI provider const runId = claims.run_id || claims.runId; // provider-specific if (!repo || !workflow || !runId) { throw new Error("Missing required identity claims for SPIFFE mapping"); } // SPIFFE-like URI: return `spiffe://example-ci/workload/${repo}/${workflow}/${runId}`; } /** * This endpoint accepts an OIDC token, validates it, and returns: * - the derived SPIFFE identity * - a "certificate-ish" payload that includes the SAN we will later check * * For a real deployment you would mint an actual X.509 cert. * Here I keep it runnable and focused on the identity/SAN verification flow. */ app.post("/attest", async (req, res) => { try { const { oidc_token } = req.body; if (!oidc_token) return res.status(400).json({ error: "Missing oidc_token" }); const jwks = createRemoteJWKSet(new URL(JWKS_URL)); // Verify JWT signature and standard claims (issuer/audience). const { payload } = await jwtVerify(oidc_token, jwks, { issuer: OIDC_ISSUER, audience: EXPECTED_AUD, }); // Map claims -> SPIFFE-like identity const spiffe = spiffeFromClaims(payload); // Create a short-lived "certificate SAN payload" // (demo replacement for actual X.509 issuance) const now = Math.floor(Date.now() / 1000); const expiresAt = now + 60; // 60s lifetime like ephemeral trust const san = spiffe; // SAN: we store the identity string here const proof = crypto .createHmac("sha256", process.env.CERT_SIGNING_SECRET || "dev-secret") .update(`${san}|${expiresAt}`) .digest("hex"); res.json({ identity: spiffe, cert: { // In real mTLS you'd inspect peer certificate SAN. // This payload models the SAN check side. san, expiresAt, proof, }, }); } catch (e) { res.status(401).json({ error: "Attestation failed", detail: e.message }); } }); app.listen(3000, () => { console.log("Attestation proxy listening on http://localhost:3000"); });
Run proxy (with real env vars)
export OIDC_ISSUER="https://example-issuer" export JWKS_URL="https://example-issuer/.well-known/jwks.json" export EXPECTED_AUD="example-audience" export CERT_SIGNING_SECRET="super-secret" node server.js
Step 2: Protected service (enforce Zero Trust SAN identity)
Now I built the “service gate” that requires the caller to present the attestation result. The gate checks:
expiresAtis still valid (short-lived trust)- the HMAC proof matches
san|expiresAt(prevents tampering) - the SAN identity equals what the service expects for that route
Service gate
// service.js import express from "express"; import crypto from "crypto"; const app = express(); app.use(express.json()); function verifyCertPayload(cert) { const { san, expiresAt, proof } = cert || {}; if (!san || !expiresAt || !proof) return false; const now = Math.floor(Date.now() / 1000); if (now > expiresAt) return false; const expected = crypto .createHmac("sha256", process.env.CERT_SIGNING_SECRET || "dev-secret") .update(`${san}|${expiresAt}`) .digest("hex"); return crypto.timingSafeEqual(Buffer.from(proof), Buffer.from(expected)); } // For demo: map route -> expected SPIFFE prefix. // In real life this could be route-specific policies. function expectedIdentityForRoute(routeName) { if (routeName === "vault-read") { // Example policy: only workflows running in a particular repo prefix are allowed. return "spiffe://example-ci/workload/org/repo"; } return ""; } app.post("/vault/read", (req, res) => { try { const { cert } = req.body; if (!verifyCertPayload(cert)) { return res.status(403).json({ error: "Invalid or expired attestation cert" }); } const expectedPrefix = expectedIdentityForRoute("vault-read"); const actualSan = cert.san; // This is the SAN identity check. if (!actualSan.startsWith(expectedPrefix)) { return res.status(403).json({ error: "SAN identity mismatch", expectedPrefix, actualSan, }); } res.json({ ok: true, data: "sensitive stuff protected by Zero Trust SAN identity" }); } catch (e) { res.status(500).json({ error: "Server error", detail: e.message }); } }); app.listen(4000, () => console.log("Protected service on http://localhost:4000"));
Run service
export CERT_SIGNING_SECRET="super-secret" node service.js
Step 3: End-to-end test with a simulated OIDC token (runnable)
Because CI providers vary, I added a deterministic simulation so this post stays runnable. I’ll mint a JWT with the right claims and then feed it into the proxy.
This part uses jsonwebtoken for quick simulation.
Simulate OIDC token issuance
npm i -S jsonwebtoken
// simulate.js import jwt from "jsonwebtoken"; const ISSUER = process.env.SIM_ISSUER || "https://example-issuer"; const AUD = process.env.SIM_AUD || "example-audience"; const secret = process.env.SIM_SIGNING_SECRET || "oidc-demo-signing-secret"; // This mimics claims many CI OIDC providers include, but names can differ. const claims = { iss: ISSUER, aud: AUD, repository: "org/repo", workflow: "deploy-prod", run_id: "12345", // jwtVerify will check iss/aud in the proxy, signature in a real provider setup. }; // IMPORTANT: The proxy above expects real JWKS validation. // For a fully runnable demo without JWKS, you would either: /// 1) adjust proxy to accept HS256 locally, or // 2) run an actual JWKS-backed token issuer for testing. /// /// Here I’m generating the token just to show the claim mapping. // In this blog repo scenario you'd replace the validation strategy for local testing. const token = jwt.sign(claims, secret, { algorithm: "HS256" }); console.log(token);
In the real pattern (the one you’d deploy), the proxy validates JWTs using JWKS from the CI provider. For local-only runnable testing, the clean approach is to temporarily modify the proxy to accept HS256 for development—while keeping production strict.
Why the SAN identity trick matters in Zero Trust
The thing I learned while building this: it’s not enough to “validate a token.” A Zero Trust system must also connect that validation to the actual authorization decision at the edge.
Using a SAN-like identity gives two practical benefits:
-
The authorization decision becomes a certificate/SAN check
Instead of carrying “claims logic” across every service, you can centralize mapping/verification and then enforce identity in a standard way. -
Short-lived trust reduces blast radius
Even if something leaks, the identity expires quickly (I used 60 seconds in the demo).
This combination—OIDC validation + derived SPIFFE identity + SAN enforcement—turned a messy “CI is trusted” assumption into explicit, verifiable, per-connection trust.
Conclusion
I built a Zero Trust-ish CI runner access pattern where an attestation proxy validates an OIDC token, maps it into a SPIFFE-like identity, and expresses that identity as a SAN-style field that the protected service enforces during each request. The key lesson from the tinkering: Zero Trust works best when identity verification is tied directly to the final enforcement point using short-lived credentials and a standard identity representation.