TILLSECRETS · CONFIG STORE

Versioned, encrypted config.

GA

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

01Model

Projects, environments, keys

Four levels, top to bottom:

  • Project — one per app or service. Owns environments and the service tokens.
  • Environmentdev, 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 current pointer.
02Write & read

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.

bash
# 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
CommandWhat it does
secrets set KEY --envCreate the key or add a new version. Prompts for the value unless you pass --value.
secrets ls --envList 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.
03Never on a client

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.

bash
# 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
--typeWhat it mintsOptions
randomN random bytes, text-encoded — tokens, session secrets.--bytes (8–512) · --encoding hex|base64|base64url
passwordA random shell/URL-safe passphrase.--length (8–256) · --symbols
hmacSymmetric signing key (same shape as random).--bytes · --encoding
ed25519Ed25519 keypair — private sealed, public returned.--format pem|jwk|jwks
rsaRSA keypair — private sealed, public returned.--format pem|jwk|jwks · --rsa-bits 2048|3072|4096
What comes back
For symmetric material (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.
04Versions

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.

bash
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 2

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

Immutable by design
Because versions never mutate, the audit log can point at an exact version for every read and rollback. “What was in prod on the 3rd, and who read it?” is answerable.
05Tokens

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.

ScopeGrants
readPull the environment (static values + the list of dynamic keys) and lease dynamic credentials. The right default for an app.
read_writeEverything 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.
bash
# 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, irreversible
Bearer only
Send the token as Authorization: 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.
Per-key scope — least privilege
Beyond project and environment, a token can be pinned to an explicit set of keys (--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.
The token is the one bootstrap secret
There are two different secrets here, and they follow different rules. The payload — the values in the vault — need never touch a client: they’re minted server-side and pulled straight into a process at boot. The token is a different thing: it’s the client’s credential to the vault — the one bootstrap secret (“secret zero”) that has to exist so a headless host can prove it’s allowed to pull. Keep it minimal: read-only, --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.
06API

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:

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

07SDKs

The SDKs

Three packages, one client underneath:

PackageRuntimeEntry point
@tillstack/secrets-nodeNode 18+load() injects into process.env; createClient() reads TILLSECRETS_* from the environment.
@tillstack/secrets-edgeAny fetch runtimeCloudflare Workers, Vercel, Deno, Bun. createClient({ url, token }) — pass them explicitly.
@tillstack/secrets-coreRuntime-agnosticThe SecretsClient both wrap. Use it directly to inject a custom fetch.

The core client

ts
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 — the ts_… token. Required.
  • TILLSECRETS_URL — API base. Optional; defaults to https://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 }).

08Sync

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:

TargetWrites to
cloudflareWorker / Pages secrets.
vercelProject environment variables.
railwayService variables.
githubActions / repository secrets.
aws_ssmSSM Parameter Store (SecureString).
dotenvA 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.

09Delivery

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:

bash
# 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')
PostureHowWhen
On-disk / env filesecrets 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 injectionsecrets 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 pullThe 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.
Env-first is the safe default when you mix them
A common pattern is to keep resilient core config (a database URL, the platform's own vars) in the environment and let the vault fill only the gaps — a signing key, an API token. Read the env var first and fall back to the vault, so a vault hiccup never overrides a deliberately-set local value and never takes down the config your service can't boot without. You move a key to the vault simply by removing it from the env — no code change.

Next: Dynamic secrets for credentials that expire on their own, or Security for the encryption and audit model. Back to the TillSecrets overview.