TRUSTIVAN holds several kinds of secret, and they are handled differently on purpose — the differences are the interesting part. 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

The 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 carrying findings: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.
The cost is real and recorded: policy-as-code, where a repository is the source of truth for rules, needs a principal that can write policy. That will be a deliberate future decision with its own controls, not something arrived at by leaving a scope grantable.

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 are HttpOnly 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 a whsec_ 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