Workload SVIDs
BetaA workload identity already lets a job, function, pod or server sign in to TillSecrets with no stored token. With a SPIFFE path it can also prove who it is to your own services: it asks for a short-lived JWT-SVID, or an X.509-SVID for mutual TLS, and the service it calls checks it against your workspace's public trust bundle. No shared secret sits between the two.
Give an identity a SPIFFE path
Every workspace is its own SPIFFE trust domain, <workspace-id>.tilldev.dev. An identity's path names the workload inside it, so its SPIFFE ID is spiffe://<workspace-id>.tilldev.dev<path>.
# Any workload identity can carry a SPIFFE path
tilldev secrets identities create --project web --env prod --github acme/api --branch main --svid-path /payments/api
# ✓ Created identity api
# SPIFFE ID spiffe://3f2a9c1e-….tilldev.dev/payments/api
# Add one to an identity you already have, or stop issuing SVIDs
tilldev secrets identities update <id> --project web --svid-path /payments/api
tilldev secrets identities update <id> --project web --svid-path noneIn the dashboard, set SPIFFE path when you create or edit an identity under Secrets → Identities. Paths are segments of letters, digits, ., _ and -, up to 255 characters, with no . or .. segment. Setting or changing one waits for your approval, like any change to what an identity trusts.
Ask for an SVID
The workload signs in with its identity as usual, then asks for an SVID naming the services that should accept it:
# On the workload: only the token goes to stdout
SVID="$(TILLSECRETS_IDENTITY=<id> tilldev secrets svid --audience billing --ttl 5m)"
curl -H "Authorization: Bearer $SVID" https://billing.internal/chargeimport { createClient } from '@tillstack/secrets-node'
// Signs in as TILLSECRETS_IDENTITY, the same way load() does.
const secrets = createClient()
const { token, spiffeId, expiresAt } = await secrets.svid({ audience: 'billing', ttlSeconds: 300 })
await fetch('https://billing.internal/charge', { method: 'POST', headers: { authorization: `Bearer ${token}` } })
console.log(spiffeId, expiresAt)| Setting | Rule |
|---|---|
| Audience | 1 to 5 services, each up to 256 characters with no spaces |
| Lifetime | 5 minutes by default, 1 minute to 1 hour, and never past the token that asked |
| Rate | At most 300 a minute per identity |
Only a token from a workload identity with a SPIFFE path can ask; a plain service token, an agent token or a person can't. The answer is sealed like a pull, and a token bound to a host key gets one only on that host.
Check one
The service that receives the SVID verifies it against your workspace's bundle. In Node, SvidVerifier fetches the bundle, keeps it for its refresh hint, and fetches it again when it sees a key it doesn't know:
import { SvidVerifier, SvidError } from '@tillstack/secrets-node'
const svids = new SvidVerifier({
orgId: '3f2a9c1e-4b5d-4e6f-8a7b-9c0d1e2f3a4b',
audience: 'billing',
allow: ['spiffe://3f2a9c1e-4b5d-4e6f-8a7b-9c0d1e2f3a4b.tilldev.dev/payments/'],
})
export async function caller(req: Request): Promise<string | null> {
const bearer = req.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''
try {
const v = await svids.verify(bearer)
return v.spiffeId
} catch (e) {
if (e instanceof SvidError) return null
throw e
}
}It accepts only ES256, checks the trust domain, the audience and the expiry (with 30 seconds of clock skew), and with allow, only the SPIFFE IDs you list. An entry ending in / allows every path under it. From a shell:
tilldev secrets svid verify "$SVID" --audience billing --org <workspace-id> --allow-id spiffe://<workspace-id>.tilldev.dev/payments/
# ✓ Valid SVID for spiffe://<workspace-id>.tilldev.dev/payments/api
# Offline, against a saved bundle; exit 1 when it fails
tilldev secrets svid verify - --audience billing --org <workspace-id> --bundle bundle.json < svid.txtsub it should accept.https://tilldev.dev/api/v1/secrets/workload/svid/<workspace-id>/bundle
https://tilldev.dev/api/v1/secrets/workload/svid/<workspace-id>/ca.pem
https://tilldev.dev/api/v1/secrets/workload/svid/<workspace-id>/jwks.json
https://tilldev.dev/api/v1/secrets/workload/svid/<workspace-id>/.well-known/openid-configurationX.509-SVIDs
For mutual TLS, the workload asks for an X.509-SVID instead: a certificate naming its SPIFFE ID, signed by your workspace's CA. The workload makes its own key pair and sends only the public key, so the private key never leaves the machine that uses it.
# On the workload: the key pair is made here, and only the public half is sent
TILLSECRETS_IDENTITY=<id> tilldev secrets svid x509 --out /run/svid --ttl 1h --renew
# ✓ spiffe://<workspace-id>.tilldev.dev/payments/api until …
# /run/svid/svid.pem the certificate
# /run/svid/svid.key its private key (0600)
# /run/svid/bundle.pem the CAs to trust
# Check a certificate a peer presented; exit 1 when it fails
tilldev secrets svid verify --cert peer.pem --org <workspace-id> --allow-id spiffe://<workspace-id>.tilldev.dev/payments/With --renew the command keeps running and replaces the three files at half their life, each with an atomic rename, so a proxy or server that rereads them never sees a half-written file. In Node:
import { createServer } from 'node:https'
import type { TLSSocket } from 'node:tls'
import { createClient } from '@tillstack/secrets-node'
const secrets = createClient()
const { certificate, privateKey, bundle, expiresAt } = await secrets.x509Svid({ ttlSeconds: 3600 })
console.log('renew before', expiresAt)
const ALLOWED = 'URI:spiffe://3f2a9c1e-4b5d-4e6f-8a7b-9c0d1e2f3a4b.tilldev.dev/payments/api'
createServer({ cert: certificate, key: privateKey, ca: bundle, requestCert: true, rejectUnauthorized: true }, (req, res) => {
const peer = (req.socket as TLSSocket).getPeerCertificate()
if (peer.subjectaltname !== ALLOWED) {
res.writeHead(403).end()
return
}
res.end('hello, payments')
}).listen(8443)Ask again before expiresAt and hand the new pair to server.setSecureContext. A TLS stack that trusts bundle.pem (or the public ca.pem) checks the chain; your code still decides which SPIFFE IDs to let in. verifyX509Svid does both against the SPIFFE bundle, checking the chain, the trust domain, the expiry and the allow list:
import { verifyX509Svid, svidTrustDomain, SvidError } from '@tillstack/secrets-node'
const org = '3f2a9c1e-4b5d-4e6f-8a7b-9c0d1e2f3a4b'
const bundle = await (await fetch(`https://tilldev.dev/api/v1/secrets/workload/svid/${org}/bundle`)).json()
export async function peerId(chainPem: string): Promise<string | null> {
try {
const v = await verifyX509Svid(chainPem, { bundle, trustDomain: svidTrustDomain(org), allow: [`spiffe://${svidTrustDomain(org)}/payments/`] })
return v.spiffeId
} catch (e) {
if (e instanceof SvidError) return null
throw e
}
}| Setting | Rule |
|---|---|
| Key | P-256, made by the workload; only the public key is sent |
| Lifetime | 1 hour by default, 5 minutes to 24 hours, and never past the token that asked or the CA |
| Rate | Shared with JWT-SVIDs: at most 300 a minute per identity |
Subject O=TillDev
Issuer O=TillDev, CN=<workspace-id>.tilldev.dev (the workspace CA)
Key P-256, made by the workload
Signature ECDSA with SHA-256
SAN URI:spiffe://<workspace-id>.tilldev.dev/payments/api
Usage digital signature; TLS client and server auth; not a CA
Validity from a minute ago to the TTL, never past the CASigning keys and rotation
The first JWT-SVID makes the workspace's signing key, and the first X.509-SVID makes its CA: P-256 keys minted inside the vault and stored encrypted. The dashboard and the API can't sign with them or read them; only the service that issues SVIDs opens one, for one signature at a time.
tilldev secrets svid keys
# trust domain 3f2a9c1e-….tilldev.dev
# bundle https://tilldev.dev/api/v1/secrets/workload/svid/3f2a9c1e-…/bundle
# KEY STATE MADE
# q3V0b2x… active 4 days ago
tilldev secrets svid rotate # retire the active key and CA; the next SVID gets a new one
tilldev secrets svid rotate --kind x509 # just the CA
tilldev secrets svid distrust <kid> # take a key or CA out of the bundle now- Keys and CAs rotate on their own after 30 days. The next SVID after that is signed with a new one. A CA certificate is valid for 45 days, so it outlives its rotation.
- A retired key stays in the bundle for just over an hour, and a retired CA for just over a day, so SVIDs they signed run out normally and verifiers have refreshed.
- Distrust takes a key or CA out of the bundle at once: every SVID it signed stops verifying as soon as verifiers refresh. Use it when a key may be exposed.
- Rotating and distrusting are for owners and admins, and wait for approval. Both are in the audit log, as is every SVID issued (but never the SVID itself).
The same keys and actions are under Secrets → Identities, in the Workload SVIDs panel, in the dashboard.
What a JWT-SVID holds
header {"alg": "ES256", "typ": "JWT", "kid": "<RFC 7638 thumbprint>"}
payload {"sub": "spiffe://<workspace-id>.tilldev.dev/payments/api",
"aud": ["billing"],
"exp": 1791201900, "iat": 1791201600,
"jti": "<random>",
"iss": "https://tilldev.dev/api/v1/secrets/workload/svid/<workspace-id>"}The header carries no crit and the verifier refuses any that does. Each SVID has a fresh jti, so a service that wants to refuse replays can keep the ones it has seen until they expire.
What this does and doesn’t cover
- SVIDs are signed with ECDSA P-256, a classical signature, because that is what SPIFFE verifiers and TLS stacks check today. The cryptography inventory lists it.
- Revoking an identity stops new SVIDs; ones already issued stay valid until they expire. Keep lifetimes short, or distrust the key to cut every SVID off at once.
- The trust domain comes from the workspace id and can't be renamed.
- X.509-SVIDs are fetched by the workload over HTTPS, not through the SPIFFE Workload API socket, so tools that only read that socket need the files from
svid x509 --out. - The CA doesn't carry name constraints, so check the trust domain and SPIFFE ID of every peer, as
verifyX509Sviddoes. - An SVID proves which workload asked; it doesn't say what that workload may do. Your service decides that from the SPIFFE ID.
API
POST https://secrets.tilldev.dev/v1/svid
Authorization: Bearer ts_… (a token from the identity's exchange)
{"audience": ["billing"], "ttl_seconds": 300}
→ 200 {"svid": "eyJ…", "spiffe_id": "spiffe://…/payments/api", "audience": ["billing"],
"expires_at": "…", "key_id": "…", "issuer": "https://tilldev.dev/api/v1/secrets/workload/svid/…"}
POST https://secrets.tilldev.dev/v1/svid/x509
{"public_key": "-----BEGIN PUBLIC KEY-----…", "ttl_seconds": 3600}
→ 200 {"certificate": "-----BEGIN CERTIFICATE-----…", "bundle": "-----BEGIN CERTIFICATE-----…",
"spiffe_id": "spiffe://…/payments/api", "expires_at": "…", "serial": "…", "key_id": "…"}
GET /api/secrets/svid trust domain, URLs and keys
POST /api/secrets/svid/rotate {"kind": "jwt" | "x509"}, or both; owner/admin, waits for approval
POST /api/secrets/svid/keys/{kid}/distrust owner/admin, waits for approvalThe bundle, CA PEM, JWK set and discovery document need no sign-in. See the API reference.