TILLSECRETS · WORKLOAD SVIDS

Workload SVIDs

Beta

A 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.

01Setup

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>.

terminal
# 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 none

In 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.

02Workload

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:

terminal
# 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/charge
client.ts
import { 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)
SettingRule
Audience1 to 5 services, each up to 256 characters with no spaces
Lifetime5 minutes by default, 1 minute to 1 hour, and never past the token that asked
RateAt 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.

03Service

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:

billing.ts
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:

terminal
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.txt
Other verifiers
The bundle follows the SPIFFE trust bundle format and is served over HTTPS with a public certificate. The same JWT keys are published as a JWK set with an OpenID discovery document, so a gateway or library that verifies JWTs by issuer or JWKS URL can check SVIDs too. Pin the audience and the sub it should accept.
public URLs
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-configuration
04Mutual TLS

X.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.

terminal
# 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:

server.ts
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:

peer.ts
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
  }
}
SettingRule
KeyP-256, made by the workload; only the public key is sent
Lifetime1 hour by default, 5 minutes to 24 hours, and never past the token that asked or the CA
RateShared with JWT-SVIDs: at most 300 a minute per identity
X.509-SVID
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 CA
05Keys

Signing 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.

terminal
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.

06Format

What a JWT-SVID holds

JWT-SVID
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.

07Limits

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 verifyX509Svid does.
  • An SVID proves which workload asked; it doesn't say what that workload may do. Your service decides that from the SPIFFE ID.
08API

API

request
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 approval

The bundle, CA PEM, JWK set and discovery document need no sign-in. See the API reference.