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 scr_… | Soft-delete the key — it stops resolving in pulls, but its version history is retained. |
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| --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 |
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.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.
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) and lease dynamic credentials. The right default for an app. |
read_write | Everything read can do, plus revoking a dynamic 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 revoke dynamic leases.
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.--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, on the roadmap.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" ]
# }It returns { secrets, dynamic }: the decrypted key→value map, plus the names of any dynamic keys in the environment (their values are leased, never returned here). An env-pinned token needs no body; a project-scoped token must pass environment_id, and can only reach environments inside its own project.
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 }).
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 | A generated .env for local use. |
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. |
Next: Dynamic secrets for credentials that expire on their own, or Security for the encryption and audit model. Back to the TillSecrets overview.