Versioned, encrypted config.
GAThe static config store holds the values you set and version: API keys, connection strings, feature flags. Everything is envelope-encrypted at rest, every change is an immutable version you can roll back to, and the whole thing is pulled at boot over one authenticated call.
Projects, environments, keys
Four levels, top to bottom:
- Project — one per app or service. Owns environments and the service tokens.
- Environment —
dev,staging,prod, or whatever you name. This is the unit you pull and the unit a token is (optionally) pinned to. - Key — env-var-style:
^[A-Z_][A-Z0-9_]*$. Each key is either static (a stored value) or dynamic (a leased credential). - Version — every write to a static key creates a new immutable version and advances a
currentpointer.
Set, list, reveal
Writes require an admin or owner role. Listing shows keys and metadata but never values — the only way to see a plaintext is an audited get / reveal.
# Prompts for the value (hidden) if you omit --value.
tilldev secrets set STRIPE_KEY --env env_…
tilldev secrets set DATABASE_HOST --env env_… --value db.internal.example.com
# List keys + metadata (kind, current version, updated_at) — never values.
tilldev secrets ls --env env_…
# Reveal one value. This is an AUDITED read; --version defaults to current.
tilldev secrets get scr_… --version 2| Command | What it does |
|---|---|
secrets set KEY --env | Create the key or add a new version. Prompts for the value unless you pass --value. |
secrets ls --env | List keys, kind, and current version. No values. |
secrets get scr_… | Reveal a plaintext (current, or --version N). Admin-only and always written to the audit log. |
secrets rm KEY --env | Soft-delete the key — it stops resolving in pulls, but its version history is retained. Takes the key like set, an id, or a secretref://. |
Generate in the vault
set assumes you already have the value. But when a secret has to be created — a session secret, an HMAC key, a signing keypair — generating it with openssl or node -e on your laptop means the plaintext is born on a client and only then handed to the vault. secrets gen closes that gap: the value is minted server-side with node:crypto, sealed under your org DEK, and never returned. You can provision a credential without ever seeing it.
# MINT a value in the vault. The plaintext is generated server-side with
# node:crypto and NEVER returned — perfect for anything you'd otherwise pipe
# out of openssl/node and paste in (which puts the secret on your machine).
tilldev secrets gen SESSION_SECRET --env env_… --type random --bytes 32
tilldev secrets gen ADMIN_PASSWORD --env env_… --type password --length 40 --symbols
tilldev secrets gen WEBHOOK_HMAC --env env_… --type hmac --encoding hex
# Keypairs: the PRIVATE half is sealed in the vault; only the PUBLIC half and the
# SSH fingerprint come back. Add --format jwk to store/return JWKs instead of PEM.
tilldev secrets gen DEPLOY_KEY --env env_… --type ed25519
tilldev secrets gen JWT_SIGNING --env env_… --type rsa --rsa-bits 4096
# → Generated JWT_SIGNING → v1 — minted in the vault; the value never touched this machine
# Post-quantum keypairs work the same way.
tilldev secrets gen RELEASE_SIGNING_KEY --env env_… --type ml-dsa-65
# A TillForge token, straight into the vault (it acts with YOUR repo access).
tilldev secrets gen FORGE_TOKEN --env env_… --type forge-token \
--repo api --scope write --expires 30| --type | What it mints | Options |
|---|---|---|
random | N random bytes, text-encoded — tokens, session secrets. | --bytes (8–512) · --encoding hex|base64|base64url |
password | A random shell/URL-safe passphrase. | --length (8–256) · --symbols |
hmac | Symmetric signing key (same shape as random). | --bytes · --encoding |
ed25519 | Ed25519 keypair — private sealed, public returned. | --format pem|jwk|jwks |
rsa | RSA keypair — private sealed, public returned. | --format pem|jwk|jwks · --rsa-bits 2048|3072|4096 |
forge-token | A TillForge tfp_ token issued to you. The prefix, scope and expiry come back. | --repo · --scope read|write|admin · --expires (days, default 30) · --token-name |
ml-dsa-65 | Post-quantum signing keypair (FIPS 204). | --format pem|raw |
slh-dsa | Hash-based post-quantum signing keypair (FIPS 205). | --format pem|raw · --slh-set sha2-128s|… |
ml-kem-768 | Post-quantum key-encapsulation keypair (FIPS 203). | --format pem|raw |
x-wing | Hybrid ML-KEM-768 + X25519 key-encapsulation keypair. | raw only |
ml-dsa-65-ed25519 | Hybrid signing keypair: both signatures must verify. | --format pem|raw |
The post-quantum types, their formats and how to use them are in Post-quantum keys.
random, password, hmac) the response carries only a descriptor — byte length + encoding — never the value. For a keypair it carries the public half plus the SSH fingerprint and authorized_keys line, which are safe to distribute. The private half stays sealed; reveal it later (audited) only if a consumer truly needs the raw key. Generation is a versioned write, so gen on an existing key rotates it in place. You can do the same from the console — the Generate in vault tab on any environment.For a TillForge token that lives only as long as a job, use a forge_token backend instead: each lease mints a token that expires with the lease.
Immutable versions & rollback
A version is never edited or overwritten. Setting a key stores a new ciphertext under the next version number; the live value is whichever version the current pointer names. Rollback doesn’t restore or copy anything — it just moves that pointer to an older version, which is why it’s instant and lossless.
tilldev secrets versions scr_…
# v3 2026-07-10 "rotate live key" (current)
# v2 2026-06-02 "promote staging"
# v1 2026-05-11 "initial"
# Roll back = move the "current" pointer to an older, unchanged version.
tilldev secrets rollback scr_… --version 2A soft-delete (secrets rm) removes a key from pulls but keeps every version, so a delete is auditable and recoverable. Nothing about a value’s history is destroyed by day-to-day use.
Sensitivity tiers
Every environment has a sensitivity from 0 to 10. A secret can have its own tier as well. The tier that applies is the higher of the two, and a secret with no tier of its own uses the environment’s. That lets one signing key in a busy environment be locked down without restricting everything around it.
# Tier a whole environment, or one secret in it.
tilldev secrets env update prod --sensitivity 8
tilldev secrets set STRIPE_SECRET_KEY --env prod --sensitive # --sensitive = 7
tilldev secrets update STRIPE_SECRET_KEY --env prod --sensitivity 9
tilldev secrets update STRIPE_SECRET_KEY --env prod --sensitivity inherit
# A project-wide token gets the rest of the environment; restricted and sealed keys are withheld.
tilldev secrets pull --env prod
# error: withheld restricted or sealed key(s): STRIPE_SECRET_KEY — they need a token pinned to this environment
tilldev secrets pull --env prod --allow-partial # warn and continue without themFrom tier 7 up, a secret is restricted:
| Action | Rule at tier 7+ |
|---|---|
| Pull (service token) | Only a token pinned to this environment. Others get the key name in withheld, never the value. |
| Reveal | Owner only. |
| Broker use, allowlist and consent | Owner only (refusal not_entitled). |
| TillForge CI | Owner binds the repo; the value reaches protected branches only. |
| Lowering below 7 | Owner only, for the environment and for each secret. |
secrets pull and secrets exec --env stop when a key is withheld, so an app never boots quietly without a value it expects. Pass --allow-partial if running without it is fine. The API and SDKs return the names in withheld.Service tokens & scopes
A ts_… service token is what a non-human client authenticates with. Each token is scoped to one project, carries a scope, and can optionally be pinned to one environment. It can also be given an expiry.
| Scope | Grants |
|---|---|
read | Pull the environment (static values + the list of dynamic keys). The right default for an app. |
read_write | Everything read can do, plus leasing dynamic credentials and revoking a lease early. It does not let a token set static values — writes are admin-gated in the dashboard/CLI. |
# read → pull only. read_write → also lease and revoke dynamic credentials.
tilldev secrets tokens create --project prj_… --scope read --env env_…
tilldev secrets tokens create --project prj_… --scope read_write
# Per-key scope: pin the token to EXACTLY these keys (needs --env). The token can
# then pull ONLY these — nothing else in the environment. Ideal for a service that
# reads one signing key at boot.
tilldev secrets tokens create --project prj_… --env env_… \
--keys TILLGATE_ATTEST_SIGNING_KEY
# --quiet: emit ONLY the raw token to stdout (confirmation goes to stderr) so it can
# be piped straight to the host that needs it — never rendered to a human screen.
tilldev secrets tokens create --project prj_… --env env_… \
--keys TILLGATE_ATTEST_SIGNING_KEY --quiet | ssh host 'read T && install-token "$T"'
tilldev secrets tokens ls --project prj_…
tilldev secrets tokens revoke tok_… --project prj_… # instant, irreversibleAuthorization: Bearer ts_… — never in a query string or path, where it would land in logs. It’s stored sha256-hashed; if it leaks, revoke kills it immediately.--host-key @host-key.pub (or Host key in the console) binds a token to a key made on the host with tilldev secrets host-key init. The token then only answers encrypted to that key, so a copy of it elsewhere gets nothing. See Encrypted delivery.--keys, or the Restrict to keys field in the console). A pinned token pulls only those keys — a sibling it wasn’t granted is never returned, and never even decrypted for it. This is the least-privilege fit for the selective in-app pull posture: a service that reads one signing key at boot gets a token that can read only that key.--keys-scoped, revocable, shown once. To keep even the token off a human screen, --quiet pipes the raw token straight to the host (above). The end state — where there’s no static token at all, because the host authenticates by what it is (a platform/device attestation) and the vault mints short-lived credentials — is workload identity, which isn’t available yet.The pull endpoint
Everything above sits on top of one HTTP call. All the SDKs make it for you, but it’s plain enough to hit directly:
curl -X POST https://tilldev.dev/api/secrets/pull \
-H "authorization: Bearer $TILLSECRETS_TOKEN" \
-H "content-type: application/json" \
-d '{}' # env-pinned token. A project-scoped token sends {"environment_id":"env_…"}
# → {
# "secrets": { "STRIPE_KEY": "sk_live_…", "DATABASE_HOST": "db.internal.example.com" },
# "dynamic": [ "DATABASE_URL" ],
# "withheld": [] # restricted or sealed keys this token may not read
# }It returns { secrets, dynamic, withheld }: the decrypted key→value map, the names of any dynamic keys in the environment (their values are leased, never returned here), and the names of restricted keys this token may not read. An env-pinned token needs no body; a project-scoped token must pass environment_id, and can only reach environments inside its own project.
With the x-tillsecrets-seal-key and x-tillsecrets-seal-nonce headers, the same object comes back encrypted to that key. The CLI and SDKs always send them; a token bound to a host key requires them. See the format.
The SDKs
Three packages, one client underneath:
| Package | Runtime | Entry point |
|---|---|---|
@tillstack/secrets-node | Node 18+ | load() injects into process.env; createClient() reads TILLSECRETS_* from the environment. |
@tillstack/secrets-edge | Any fetch runtime | Cloudflare Workers, Vercel, Deno, Bun. createClient({ url, token }) — pass them explicitly. |
@tillstack/secrets-core | Runtime-agnostic | The SecretsClient both wrap. Use it directly to inject a custom fetch. |
The core client
import { SecretsClient } from '@tillstack/secrets-core'
const client = new SecretsClient({
url: 'https://tilldev.dev', // optional; this is the default
token: process.env.TILLSECRETS_TOKEN!,
environmentId: process.env.TILLSECRETS_ENV, // only for project-scoped tokens
})
const { secrets, dynamic } = await client.pull()
const key = await client.get('STRIPE_KEY') // convenience over pull()Environment variables the Node SDK reads
TILLSECRETS_TOKEN— thets_…token. Required.TILLSECRETS_URL— API base. Optional; defaults tohttps://tilldev.dev.TILLSECRETS_ENV— environment id. Optional; only needed when the token is project-scoped rather than pinned to one environment.
load() follows dotenv’s rule — a variable already present in process.env is left alone unless you pass load({ override: true }). It throws when a restricted key is withheld from the token, unless you pass load({ allowPartial: true }).
Sync targets
Not every workload can call the pull API — a CI build, a serverless platform that only reads its own env vars, a container that boots before your code runs. For those, a sync target pushes an environment’s current values into the platform you already use. TillSecrets stays the source of truth; the platform just sees plain env vars.
The targets are vendor-agnostic:
| Target | Writes to |
|---|---|
cloudflare | Worker / Pages secrets. |
vercel | Project environment variables. |
railway | Service variables. |
github | Actions / repository secrets. |
aws_ssm | SSM Parameter Store (SecureString). |
dotenv | Your https endpoint, POSTed as a .env body. |
custom | Your https endpoint, POSTed as JSON {"secrets": {…}}. |
Give a dotenv or custom target a secret and every push is signed in x-tillsecrets-webhook-signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of t.raw_body with that secret, the same scheme as TillAuth webhooks. Check it over the raw body in constant time and refuse a t more than five minutes off, so a captured push can’t be replayed later to roll your values back. x-tillsecrets-signature: sha256=<hex> (the body alone, no timestamp) is still sent for receivers written before; it doesn’t stop a replay.
Each target’s credentials are encrypted at rest under the same per-org key as your secrets, and every push is written to the audit log as a sync.push. Configure targets from the dashboard under Secrets → Sync. The same environment can fan out to more than one target — no lock-in to any single platform.
Getting config into your app — pick your posture
There isn't one right way to hand a secret to a running service — it's a trade between simplicity, secret hygiene, and how much you want boot to depend on the vault. TillSecrets ships all three postures so you can choose per service, and even mix them:
# 1. On-disk — the platform holds env vars (simplest; secret lives in a file).
tilldev secrets pull --env env_… > .env # or push via a sync target
# 2. Boot-time injection — the vault IS the manifest; nothing on disk.
TILLSECRETS_TOKEN=ts_… TILLSECRETS_URL=https://tilldev.dev \
tilldev secrets exec --env prod -- node server.js
# 3. Selective in-app pull — the app fetches only what it needs, at runtime.
# import { SecretsClient } from '@tillstack/secrets-core'
# const signingKey = await client.get('JWT_SIGNING_KEY')| Posture | How | When |
|---|---|---|
| On-disk / env file | secrets pull or a sync target → the platform's env vars. | Simplest. The secret rests in a file, so best for lower-sensitivity or platform-managed values. |
| Boot-time injection | secrets exec --env <slug> — the whole environment into the child process, nothing on disk. | Headless hosts / systemd units. Maximum hygiene; boot depends on the vault being reachable. |
| Selective in-app pull | The SDK client.get(key) — fetch only what you need, when you need it. | Keep resilient core config local and vault-source only the sensitive keys. Degrades gracefully. |
Deleting or rotating a TillDev token
Deleting a secret, or setting a new value, does not stop the old value working wherever it was issued. For a third-party key that is up to the vendor. For credentials TillDev issued, the vault can revoke them at the source: it decrypts each version server-side, recognises TillForge tokens (tfp_) and deploy tokens (tfd_), TillSecrets service and agent tokens (ts_, ta_), TillCache tokens (tc_) and API keys (tp_) that your org issued, and matches them by hash. The value never leaves the vault.
# Rotating a TillDev token: the new value replaces the old one, and the old token dies now.
tilldev secrets gen FORGE_TOKEN --env prod --type forge-token --repo api --scope write --revoke-previous
printf %s "$NEW_TOKEN" | tilldev secrets set CI_TOKEN --env prod --value-stdin --revoke-previous
# Deleting one: revoke what any version held, in the same call.
tilldev secrets rm FORGE_TOKEN --env prod --revoke-linked
# Look first, or clean up later.
tilldev secrets linked FORGE_TOKEN --env prod
tilldev secrets linked revoke FORGE_TOKEN --env prod # only what older versions hold
tilldev secrets linked revoke FORGE_TOKEN --env prod --all # the current value's too- In a terminal,
secrets rm,setandgenlist any token that still works and ask whether to revoke it. Without a terminal they never revoke on their own: they warn and print the command. - In the console, Delete shows the tokens with an also revoke box, and saving a new value offers to revoke the old one.
- A rotation revokes only what older versions hold. Setting the same token again is not a rotation.
- Each revocation is logged in the product that issued the token and as
credential.revokein the secrets audit log.
Next: Dynamic secrets for credentials that expire on their own, or Security for the encryption and audit model. Back to the TillSecrets overview.