Credentials, leased and expiring.
GAA dynamic secret has no stored value. Instead of a long-lived database password sitting in your config, an app leases a fresh credential when it needs one — minted on your backend, valid for minutes, and revoked automatically when the lease ends. TillSecrets keeps only a reference to the lease; it never stores the credential itself.
Backends, keys, leases
Three pieces:
- A backend is registered once per project — something that can mint and revoke credentials. There are two families. Upstream backends mint on infrastructure you own, with their admin config stored envelope-encrypted: postgres, mysql, redis, aws_iam and custom (your own HTTPS endpoint). Self-issued backends are generated by TillSecrets with no upstream to connect to: api_key, keypair and forge_token.
- A dynamic key is a normal env-var-style KEY (e.g.
DATABASE_URL) whose kind is dynamic and which references a backend, with an optional TTL. - A lease is a single issuance: a short-lived credential handed to a caller, with an expiry. We persist a lease reference (the minted role or user name, a key fingerprint, or your endpoint’s reference) and the expiry — nothing that can be replayed.
A pull lists dynamic keys under dynamic: [...] so your app knows they exist — but their values only come from a lease.
Register a Postgres backend
A backend is registered from the dashboard under Secrets → Dynamic backends (admin only), or with tilldev secrets backends create --type <type> --config-stdin, which reads the config object as JSON from stdin. For Postgres you provide an admin connection — one privileged enough to CREATE ROLE and DROP ROLE — plus the grants each minted role should get, and the TTL bounds:
{
"name": "app-db",
"type": "postgres",
"default_ttl_sec": 3600, // 1h — used when a lease asks for no TTL
"max_ttl_sec": 86400, // 24h — hard ceiling; requests are clamped
"config": {
"host": "db.internal.example.com",
"port": 5432,
"database": "app",
"admin_user": "tillsecrets_admin",
"admin_password": "…", // encrypted under your org DEK before it lands
"ssl": true,
"role_prefix": "tsl_", // every minted role starts with this
"grants": [
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\"",
"GRANT USAGE ON SCHEMA public TO \"{{name}}\""
]
}
}| Field | Meaning |
|---|---|
default_ttl_sec | Lease lifetime when a request names no TTL. Min 60s. |
max_ttl_sec | Hard ceiling. Any requested TTL is clamped to this; must be ≥ default. |
role_prefix | Every minted role name starts with this (default tsl_) so leased roles are obvious in your DB. |
grants | Templated SQL run against each new role. See below. |
Updating a backend
Rotate an admin password, change grants or adjust TTLs in place with Update on the backend, tilldev secrets backends update <id> --config-stdin, or PATCH /api/secrets/projects/{id}/backends/{backendId}. Send only what changes; null removes a field. Leases already out keep working. The stored config is never shown, so a change that points the backend somewhere new (host, port, TLS, a URL) must include its password or secret again; otherwise an edit could send the stored one to a server of the editor’s choosing. Changing a forge_token backend’s config makes its tokens act as you.
host is checked against an SSRF allow-list before TillSecrets ever connects, and the whole config — password included — is envelope-encrypted under your org key. The lease service decrypts it in memory only for the moment it connects.Templated SQL grants
When TillSecrets mints a role it runs each entry in grants as SQL, substituting {{name}} with the generated role name. That’s how a leased credential gets exactly the privileges you decide — and nothing more:
-- Read/write on the app schema for the life of the lease:
GRANT USAGE ON SCHEMA public TO "{{name}}";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "{{name}}";
-- Or a read-only analyst role:
GRANT SELECT ON ALL TABLES IN SCHEMA public TO "{{name}}";Behind the scenes each lease is a CREATE ROLE <prefix><random> LOGIN PASSWORD … VALID UNTIL <expiry>, followed by your grants. On revoke — or when the sweeper reaps an expired lease — TillSecrets reassigns owned objects and DROP ROLEs it. If that drop can’t reach your database it is retried, and the role’s own VALID UNTIL stops new sign-ins at expiry meanwhile. Sessions already open stay open until the drop.
MySQL & MariaDB users
Each lease is a CREATE USER with a random password, followed by your grants. In a grant, {{name}} is the new user and {{host}} its user_host. On revoke or expiry TillSecrets runs DROP USER and closes the user’s open sessions.
{
"name": "orders-db",
"type": "mysql",
"default_ttl_sec": 3600,
"max_ttl_sec": 86400,
"config": {
"host": "mysql.internal.example.com",
"port": 3306,
"database": "orders", // optional; goes into the credential's url
"admin_user": "tillsecrets_admin",
"admin_password": "…",
"tls": "verify", // "verify" (default), "require" (encrypted, cert not checked) or "off"
"ca": "-----BEGIN CERTIFICATE-----…", // optional, for a private CA
"user_prefix": "tsl_", // a-z, 0-9 and _, up to 12 characters
"user_host": "%", // the host part of 'user'@'host'
"grants": ["GRANT SELECT, INSERT, UPDATE, DELETE ON orders.* TO '{{name}}'@'{{host}}'"],
"max_user_connections": 10, // 0 = no limit
"expiry_event_schema": "tillsecrets" // optional: MySQL drops the user itself at expiry
}
}
// Lease → credential: { "username", "password", "host", "port", "database", "url": "mysql://…" }- Admin privileges.
CREATE USER, plus every privilege your grants hand out,WITH GRANT OPTION. Closing open sessions needsPROCESSandCONNECTION_ADMIN(MariaDB:CONNECTION ADMIN); without them the user is still dropped, but a session it already had stays open until it disconnects. - MySQL has no expiring password like Postgres’s
VALID UNTIL. Setexpiry_event_schemaand each lease also creates a one-off event in that schema that drops the user at expiry, even if TillSecrets can’t reach the server then. It needs theEVENTprivilege on that schema and the event scheduler on (the MySQL 8 default). The event drops the user but doesn’t close its open sessions. - TLS is
verifyby default: the server’s certificate must chain to a public CA, or toca, and matchhost. Only userequireoroffon a network you trust.
Redis & Valkey ACL users
Each lease is an ACL SETUSER with a random password and your rules. Revoke and expiry run ACL DELUSER, which also disconnects the user’s clients.
{
"name": "cache",
"type": "redis",
"default_ttl_sec": 3600,
"max_ttl_sec": 86400,
"config": {
"host": "redis.internal.example.com",
"port": 6379,
"tls": "verify", // "verify" (default), "require" or "off"
"admin_user": "default",
"admin_password": "…",
"user_prefix": "tsl_",
"rules": ["~cache:*", "+@read", "+@write", "-@dangerous"],
"acl_save": false // true: ACL SAVE after each change (needs an aclfile)
}
}
// Lease → credential: { "username", "password", "host", "port", "tls", "url": "rediss://…" }- Rules grant access only. Key patterns, channels, commands and selectors are fine; rules that set or clear a password (
>,<,#,!,nopass,resetpass), switch the user on or off, orresetit are refused, inside selectors too. - Redis or Valkey 6 or later, on one primary. ACL users don’t replicate, so point the backend at a standalone server or a single primary, not a cluster. The admin needs
+acl. - ACL users live in memory. A restart forgets them, which ends every lease. With an
aclfile, setacl_saveso each change is written withACL SAVE.
AWS credentials
The backend stores one AWS access key and uses it to make credentials in one of two ways. Both work in the aws, aws-us-gov and aws-cn partitions.
Assumed role (default)
Each lease is an sts:AssumeRole session on role_arn. AWS expires it on its own. Its lifetime is the lease TTL, raised to AWS’s minimum of 15 minutes and capped at 12 hours; if the role’s maximum session duration is shorter, it is one hour. An optional policy narrows the session below the role.
{
"name": "s3-reader",
"type": "aws_iam",
"default_ttl_sec": 3600,
"max_ttl_sec": 43200,
"config": {
"access_key_id": "AKIA…",
"secret_access_key": "…",
"mode": "assumed_role",
"partition": "aws", // "aws", "aws-us-gov" or "aws-cn"
"region": "eu-west-1", // returned with the credential
"role_arn": "arn:aws:iam::123456789012:role/app-reader",
"external_id": "…", // optional, if the role's trust policy asks for one
"policy": "{\"Version\":\"2012-10-17\",\"Statement\":[…]}", // optional: narrows the session further
"revoke_sessions": true,
"name_prefix": "tsl-"
}
}
// Lease → credential: { "access_key_id", "secret_access_key", "session_token", "expiration", "region" }STS sessions can’t be deleted, so an early revoke adds a deny for that one session to an inline policy on the role named tillsecrets-revoked-leases. Entries are removed once the session would have expired anyway. The stored key needs:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "arn:aws:iam::123456789012:role/app-reader" },
{ "Effect": "Allow", "Action": ["iam:GetRolePolicy", "iam:PutRolePolicy"], "Resource": "arn:aws:iam::123456789012:role/app-reader" }
]
}revoke_sessions to false and keep TTLs short: a revoke then only marks the lease, and the session runs until it expires.IAM user
Each lease creates an IAM user under user_path, attaches your policies, groups and optional policy, and returns a new access key. Revoke and expiry delete the key first, then the user. New keys take a few seconds to work, as AWS spreads them.
{
"name": "ci-deployer",
"type": "aws_iam",
"config": {
"access_key_id": "AKIA…",
"secret_access_key": "…",
"mode": "iam_user",
"policy_arns": ["arn:aws:iam::123456789012:policy/deploy"],
"groups": [],
"permissions_boundary": "arn:aws:iam::123456789012:policy/tillsecrets-boundary",
"user_path": "/tillsecrets/",
"name_prefix": "tsl-"
}
}
// Lease → credential: { "access_key_id", "secret_access_key", "username" }A key that can create users can create a powerful one, so require a permissions boundary: the condition below refuses any user the backend makes without it. Add iam:AddUserToGroup and iam:RemoveUserFromGroup on the group ARNs if you use groups.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["iam:CreateUser", "iam:AttachUserPolicy", "iam:PutUserPolicy"],
"Resource": "arn:aws:iam::123456789012:user/tillsecrets/*",
"Condition": { "StringEquals": { "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/tillsecrets-boundary" } }
},
{
"Effect": "Allow",
"Action": [
"iam:CreateAccessKey", "iam:ListAccessKeys", "iam:DeleteAccessKey",
"iam:ListAttachedUserPolicies", "iam:DetachUserPolicy",
"iam:ListUserPolicies", "iam:DeleteUserPolicy",
"iam:ListGroupsForUser", "iam:DeleteUser"
],
"Resource": "arn:aws:iam::123456789012:user/tillsecrets/*"
}
]
}Your own endpoint
For anything else, run an HTTPS endpoint on a public address that mints and revokes. TillSecrets calls it for each lease, keeps the lease, and calls your revoke URL when the lease is revoked or expires.
{
"name": "partner-keys",
"type": "custom",
"default_ttl_sec": 3600,
"max_ttl_sec": 86400,
"config": {
"issue_url": "https://keys.example.com/tillsecrets/issue",
"revoke_url": "https://keys.example.com/tillsecrets/revoke", // optional
"signing_secret": "…at least 32 characters…",
"auth_header": "Bearer …" // optional, sent as the authorization header
}
}POST /tillsecrets/issue
content-type: application/json
x-tillsecrets-signature: t=1767225600,v1=5f1c…
{ "event": "issue", "ttl_seconds": 3600, "expires_at": "2026-01-01T01:00:00.000Z", "request_id": "…" }
→ 200 { "credential_ref": "key_8a1f", "credential": { "api_key": "pk_live_…", "account": "acme" } }
POST /tillsecrets/revoke
{ "event": "revoke", "credential_ref": "key_8a1f", "request_id": "…" }
→ any 2xx, or 404 if it is already gone- Your issue reply is
credential_ref(printable ASCII, up to 512 characters), which you revoke by, andcredential: 1 to 32 string fields, returned to the caller once and never stored. Replies over 64 KiB, non-JSON replies and redirects fail the lease. Each call times out after 8 seconds. - Give what you mint its own expiry from
expires_at. If your reply is lost, the lease fails and TillSecrets has no reference to revoke. - Without a
revoke_url, revoking only marks the lease; the credential works until your expiry ends it.
Each request is signed the same way as TillAuth webhooks. Check the signature over the raw body before you trust it, and reject old timestamps:
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody is the request body exactly as received, before JSON parsing.
export function verifyTillSecrets(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=', 2) as [string, string]))
const t = Number(parts['t'])
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
const given = Buffer.from(parts['v1'] ?? '', 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
}API keys & key pairs
Not every secret needs an upstream. A self-issued backend has none — TillSecrets generates the credential itself with the platform CSPRNG, tracks it under a lease, and expires it on TTL just the same. Three ship today.
API-key backend
Mints a strong random token per lease — for webhook-signing secrets, per-tenant API keys, or anywhere you’d otherwise hand-roll openssl rand. You choose the length, encoding, and an optional prefix:
{
"name": "webhook-signing",
"type": "api_key",
"default_ttl_sec": 3600,
"max_ttl_sec": 86400,
"config": {
"prefix": "sk_", // optional label on the front of every token
"bytes": 32, // 16–64 random bytes (default 32)
"encoding": "base64url", // or "hex"
"revoke_webhook_url": "https://api.example.com/keys/revoke" // optional
}
}
// Lease → credential: { "token": "sk_9f2c…", "credential_ref": "<sha256(token)>" }
// Only credential_ref (the hash) is ever stored — the token is shown once.Key-pair backend
Generates a fresh asymmetric key pair per lease — ed25519 (default) or rsa — as an SSH authorized_keys line or PEM. Think short-lived deploy keys or per-session PKI. The private key is returned once and never stored:
{
"name": "deploy-key",
"type": "keypair",
"default_ttl_sec": 3600,
"max_ttl_sec": 604800,
"config": {
"algorithm": "ed25519", // or "rsa"
"rsa_modulus_bits": 3072, // only for rsa: 2048 | 3072 | 4096
"format": "ssh", // "ssh" authorized_keys line, or "pem" (SPKI)
"comment": "till-lease" // optional, appended to the SSH public line
}
}
// Lease → credential: {
// "algorithm": "ed25519",
// "private_key": "-----BEGIN PRIVATE KEY-----…", // PKCS#8 PEM, once
// "public_key": "ssh-ed25519 AAAA… till-lease",
// "public_key_ssh": "ssh-ed25519 AAAA… till-lease",
// "fingerprint": "SHA256:…" // == credential_ref
// }TillForge-token backend
Mints a TillForge tfp_ token per lease, for a CI job or an agent that has to push or clone for a while and should hold nothing afterwards. The token acts as the admin who created the backend, so their live repo access still applies each time it is used. It can be limited to one repo, and to read or write; admin is not leasable. Unlike the other self-issued types, revoking the lease revokes the token in TillForge at once.
# A backend that mints TillForge tokens. Each lease gets a fresh tfp_ token that
# expires with the lease. Tokens act as YOU (the admin who creates the backend).
tilldev secrets backends create --project infra --name ci-push --type forge_token \
--repo api --scope write --ttl 3600 --max-ttl 21600
tilldev secrets declare FORGE_TOKEN --env prod --backend <backend-id>
# In the job (read_write token pinned to prod):
tilldev secrets lease FORGE_TOKEN --ttl 1800
# → credential: { "token": "tfp_…", "scope": "write", "repo": "api" }revoke_webhook_url — TillSecrets SSRF-checks it and POSTs it the credential_ref on revoke, retrying until it answers 2xx or 404, so your consumer can drop it — or keep TTLs short and validate the credential_ref server-side.Lease a credential
First declare a dynamic key that points at the backend (admin, session-gated):
# Declare a dynamic KEY that references the backend (admin-gated).
curl -X POST https://tilldev.dev/api/secrets/environments/env_…/dynamic \
-H "content-type: application/json" \
--cookie "$SESSION" \
-d '{ "key": "DATABASE_URL", "backend_id": "bk_…", "ttl_seconds": 900 }'Then any app holding a read_write ts_… token pinned to that environment can lease it. Leasing is served by the TillSecrets lease service (a Node service — it opens real connections to upstream backends) at POST /v1/lease. The shape of credential depends on the backend: a Postgres role below; each backend above shows its own.
# Lease a credential with a read_write ts_… token (a read token can only pull).
curl -X POST "$TILLSECRETS_LEASE_URL/v1/lease" \
-H "authorization: Bearer $TILLSECRETS_TOKEN" \
-H "content-type: application/json" \
-d '{ "secret_key": "DATABASE_URL", "ttl_seconds": 900 }'
# → {
# "lease_id": "ls_…",
# "credential": {
# "username": "tsl_9f2c…", "password": "…",
# "host": "db.internal.example.com", "port": "5432", "database": "app"
# },
# "expires_at": "2026-07-10T12:15:00Z",
# "ttl_seconds": 900
# }The credential is returned once — assemble your connection string from it and use it until expires_at. Pass secret_key (resolved within the token’s environment) or a backend_id directly. The requested ttl_seconds is clamped to the backend’s max_ttl_sec.
Revoking early
A lease expires on its own, but a read_write token can hand one back sooner:
# Give the credential back early (needs a read_write token).
curl -X POST "$TILLSECRETS_LEASE_URL/v1/lease/ls_…/revoke" \
-H "authorization: Bearer $TILLSECRETS_TOKEN"
# → { "ok": true, "upstream": "dropped" } ("pending" if the upstream couldn't be reached)A background sweep drops the upstream credential of a lease within about a minute of its expires_at, so nothing lingers even if a client forgets to release it. Admins can also revoke any lease from Secrets → Leases or with tilldev secrets leases revoke.
When a drop doesn’t go through
If a revoke or an expiry can’t reach the upstream, the lease is still marked revoked or expired, but it shows as drop pending, the revoke answers upstream: "pending", and the sweep retries the drop about every five minutes, oldest first, until it succeeds. Until then the credential may still work: MySQL users (without the expiry event), Redis users, IAM users and custom credentials have no expiry of their own. List them with tilldev secrets leases --pending.
Why lease instead of store
- Short blast radius. A leaked leased credential is useless within minutes; there is no shared, long-lived database password to rotate in a panic.
- Per-instance identity. Every app instance leases its own role, so your database logs attribute activity to a real, time-boxed identity.
- Nothing to exfiltrate at rest. TillSecrets stores a lease reference and an expiry, never the password. There is no credential in Postgres to steal.
See Config store for static values and the pull API, or Security for how backend configs and leases are protected. Back to the TillSecrets overview.