The rule underneath the table: hash what you only need to verify, encrypt
what you need to reproduce — and never mistake the second for a weaker
version of the first.
API keys
The format
twk tag makes a leaked credential mechanically detectable. Secret
scanners match on vendor prefixes; an unmarked random string is
indistinguishable from a UUID, so it gets committed and nobody ever revokes it.
A credential nobody can grep for is a credential nobody revokes.
The prefix is public and safe to put in a log line, an error message or a
support ticket. Without a public half, identifying which key leaked means
storing something derived from the secret — which is how “we log a truncated
token” becomes a finding of its own.
Nothing about the tenant, the expiry or the scopes is encoded in the string. A
self-describing credential invites verification by parsing rather than by
lookup, and every deployment eventually grows one code path that trusts the
parse. The string identifies a row; the row is the truth.
The secret is returned exactly once
The full credential appears in the body of the response that created it, and nowhere else, ever. There is no read-back endpoint — not one that requires elevated permission, not one behind a confirmation dialog. It does not exist, which is what makes “we cannot leak it in a screenshot” a property of the system rather than a policy. If a secret is lost, the key is rotated, not recovered. That is why rotation is cheap: creating a key never revokes another, so both work during the overlap and the changeover is the customer’s to schedule. See Rotate an API key.Expiry is mandatory
There is no “never expires”. The default lifetime is 90 days and the maximum is 365. A credential with no expiry is one nobody will ever rotate, and the request for one is always “just for now”.A key ends with its creator’s access
A key belongs to its organization, but it stops working the moment the person who created it is no longer an active member: removed by an administrator, deprovisioned by your directory (SCIM), or suspended. The check runs on every request, so it holds whichever way the person left; removal and deprovisioning also mark each of their keys revoked, with the identity trail recording it. The same applies to the agents and workloads a person delegated — they stop while their human is suspended or gone. A key that outlived the person who made it was access nobody was accountable for. So there is no silent survival and no implicit transfer: to keep an integration running across a departure, an administrator creates a replacement key under their own name before removing the person. The removal dialog in Settings → Members lists the keys that will stop. A demotion still does not change a key’s authority (see below).Revocation is immediate, idempotent, and never a delete
Revoking takes effect on the next request. Revoking twice succeeds twice — a retried revocation during an incident must not fail. The row is never deleted. “Which credential did this, and when did we stop trusting it” is the first question after a leak, and it is asked most often about credentials that were revoked precisely because they were misused. A deleted row cannot answer it. At most 50 active keys per tenant. That is a blast-radius limit rather than a billing one: an organisation with hundreds of credentials cannot meaningfully review them, so a cap that forces the question is worth the occasional inconvenience.Storage and comparison
The secret is stored as an unsalted SHA-256 hash, and the reasoning is worth stating because “you should have used bcrypt” is the reflex. Slow password hashes exist to make dictionary attacks expensive against secrets a human chose. This secret is 32 bytes straight from a CSPRNG: there is no dictionary and no plausible guess, so the only property required of the hash is preimage resistance. A deliberately slow hash would add tens of milliseconds to every automated request, which on a CI-facing API is a self-inflicted denial of service bought in exchange for protection against an attack that cannot be mounted. Unsalted for the same reason — precomputation against a 256-bit keyspace is not a thing. Comparison is constant-time. With 256 bits of random secret a timing attack is theoretical, but “the attack is impractical” is a worse reason to compare in variable time than “it costs nothing to compare in constant time”. A prefix that matches no stored key triggers a dummy comparison doing the same work anyway. Without it, response time distinguishes “this prefix exists” from “it does not”. An attacker cannot guess a whole credential, but confirming which prefixes are real is reconnaissance that narrows a later attack and reveals how many keys a tenant holds.What a key may be granted
A key’s authority is exactly its granted scopes. It has no role, and it does not inherit its creator’s current role. That is a deliberate choice among three bad-looking options:
The consequence is the one you want: a demotion does not silently break a
pipeline, and a promotion does not silently widen a key.
No escalation
A caller may not grant a scope they do not themselves hold. Without that rule, any member could mint a key carryingfindings:suppress and use it to do
what their own role forbids — which would make an API key a
privilege-escalation primitive rather than a delegation one.
Three scopes are ungrantable to any key
apikeys:manage, policies:manage and webhooks:manage cannot be attached to
a machine credential at all. Requesting one is a 400 naming the scope, not a
silently narrowed key. Each has its own reason, and they are not the same
reason:
apikeys:manage— a key that can mint keys never really expires. An attacker holding a leaked CI credential would issue a fresh one before anyone noticed, and revoking the original would achieve nothing. This is the containment property, and a test asserts it.policies:manage— the verdict exists so that a build cannot decide its own outcome. A credential that could both request a verdict and rewrite the policy behind it is a build grading its own homework. That is not escalation of access; it is defeat of the control, which for a security product is worse.webhooks:manage— an endpoint is a destination every future event is copied to. A credential that could add one could arrange for a stream of a tenant’s security findings to be sent somewhere the tenant never chose. That is exfiltration with an audit trail that reads like configuration.
findings:suppress is separate from findings:write
Triage moves a finding around a queue everyone can still see. Suppression makes
a real, confirmed vulnerability stop appearing in the backlog, stop counting
towards the severity totals, and eventually stop blocking the release gate. It
is the one action whose misuse is invisible by design.
A CI runner that can queue scans has no business silencing its own failures.
The full scope table is at Scopes.
Session credentials
Browser sessions areHttpOnly cookies — trustivan_session — with a
trustivan_csrf companion whose value must be echoed as X-CSRF-Token on
unsafe methods.
At the API, only access tokens are accepted. A refresh token presented to
an API endpoint is rejected, so it cannot extend a session past the
access-token lifetime or survive a revocation. Defaults are 15 minutes for
an access token and 7 days for a refresh token.
The signing key has no default of any kind. A deployed environment must supply
at least 32 bytes, known placeholder values are rejected at startup, and an
unrecognised ENV is treated as deployed rather than as development. In
development the backend generates an ephemeral random key per process
instead of shipping a default — because a published default signing secret was
a real finding in this codebase’s own review, and the lesson from it was that
the fix is not a better default but no default at all.
Derived keys, not a drawer of secrets
TOKEN_ENCRYPTION_KEY is a master key. Purpose-specific keys are derived from
it with HMAC-SHA256 under a stable domain separator:
Distinct purposes get distinct keys, and knowing one reveals nothing about the
master or about any sibling. The point is that a new cryptographic purpose does
not require a new operator-supplied secret: every additional mandatory secret
is another chance for a deployment to be misconfigured, and another thing an
operator might set to the same value as an existing one. Derivation means a
deployment cannot forget one and silently fall back to an empty key — and a
forged cursor is a tenant boundary written in JSON.
Webhook secrets are encrypted, not hashed
An API key is hashed because TRUSTIVAN never needs the original again — it only needs to check one. A webhook signing secret is different: TRUSTIVAN has to reproduce the value in order to sign each delivery with it. A hash cannot do that. So webhook secrets are encrypted at rest, with a subkey derived for that purpose alone, so that a ciphertext from one purpose can never be decrypted as another. Like an API key, the secret is shown once — by the create and rotate responses — and there is no path that reads it back. It carries awhsec_
prefix for the same reason twk_ exists.
TOTP secrets and registry credentials are encrypted for the same reason and
under their own subkeys — a registry password must be presented to a registry,
and an expected TOTP code must be computed from the shared secret. A registry
credential additionally has no read path at all: unlike a webhook secret,
it is never returned even once after creation, because nothing about the
customer’s workflow requires them to read it back.
Where to go next
- Scopes — the full permission table.
- Rotate an API key — the overlap window in practice.
- Authentication errors — reading a 401 or 403 you did not expect.

