TRUSTIVAN accepts two credentials, and which one you hold decides how you are authorised.

API keys

A machine credential is presented in the Authorization header:
The credential has three parts:
Each part earns its place. The twk tag makes a leaked credential mechanically detectable — secret scanners match on vendor prefixes, and an unmarked random string is indistinguishable from a UUID, so it gets committed and nobody ever revokes it. The prefix is public and safe to log: it is what an audit row, an error message and a support ticket carry, and it is how you identify which credential leaked without storing anything derived from the secret. The secret is never logged, never returned after creation, and stored only as a SHA-256 hash. 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 stored hash is compared in 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 nothing performs the same work anyway, so “this prefix exists” and “this prefix does not” are not distinguishable by response time.

A machine principal has no role

A person’s authority comes from their role in the organisation. A machine’s authority is exactly the set of scopes its credential was granted — never a role, and never the authority of whoever created it. That distinction is deliberate and it is load-bearing. Giving a key a role would mean either freezing its creator’s role at issue time, so a demoted administrator’s key keeps administrator authority, or looking the creator up on every request, which makes the key’s authority mutable by an unrelated HR action. Neither is a property you want in a credential sitting in a CI runner. The key records who created it, for attribution only. It does not stop working when they leave. See Scopes.

Browser sessions

The dashboard authenticates with an HttpOnly cookie. POST /auth/login and POST /auth/signup set session cookies and return no credential in the body — there is nothing for JavaScript to read, and therefore nothing an XSS can exfiltrate. Because a cookie is ambient, every unsafe method (POST, PUT, PATCH, DELETE) carrying one must also carry a CSRF header. TRUSTIVAN uses double-submit: a readable trustivan_csrf cookie whose value must be echoed in the X-CSRF-Token header. A hostile page can cause your browser to send the session cookie, but it cannot read the CSRF cookie to reproduce the header.
Bearer requests are exempt from that header, and requiring the ceremony of a command-line client would buy nothing. This is the rule that ties the two mechanisms together, and it is worth stating as a rule rather than an implementation detail: TRUSTIVAN will not honour an API key presented in a cookie. Only the Authorization header. The reasoning composes from two facts already stated. A cookie is ambient — the browser attaches it to any request to this origin, whoever caused that request. An API key deliberately has no CSRF protection, precisely because it is not ambient and does not need any. Honouring a key from a cookie would combine those two properties into one credential that is both ambient and unprotected: attached automatically by the browser, and carrying no header a hostile origin cannot produce. That is the exact shape of credential CSRF protection exists to prevent, and it would be introduced by a convenience nobody asked for.

Credential lifecycle

The secret is returned exactly once

POST /api-keys returns the secret in its response body, and there is no endpoint anywhere in the API that reads it back.
If the secret is lost, it is not recovered — it is rotated. That is not an inconvenience the design failed to remove; it is the property that makes “we cannot leak it in a screenshot” true of the system rather than of a policy.

Expiry is mandatory

expires_at is optional in the request and mandatory in the result. Omit it and you get 90 days. The maximum is 365 days. There is no value meaning “never”. A credential with no expiry is one nobody will ever rotate. It outlives the project it was issued for, the person who issued it, and usually the knowledge of where it is deployed. Making expiry unavoidable turns rotation into a scheduled task rather than an incident response.

A caller may not grant a scope they do not hold

Key creation delegates authority; it does not manufacture it. Requesting a scope the caller does not themselves hold is refused. Without that rule, an API key would be a privilege-escalation primitive rather than a delegation one — anyone able to create a key could create one more capable than themselves.

Revocation

Revocation takes effect immediately and is idempotent: a revocation retried during an incident must not fail, because the person running it is already having a bad day and a 409 at that moment is actively harmful. The row is never deleted. “Which credential did this, and when did we stop trusting it” is exactly the question an incident review asks, and a deleted row cannot answer it. GET /api-keys therefore lists revoked and expired keys too — a list that hid them could not answer “what has this organisation ever issued”, which is the first question after a leak and the whole basis of an offboarding review.

Rotation

Creating a key never revokes another. The overlap is yours to control, and the order matters:
  1. Create the replacement, with the same scopes.
  2. Deploy it everywhere the old one is used.
  3. Revoke the old one, once nothing is using it.
Reversing steps two and three is an outage. Doing them as one atomic operation would be a worse outage, because there would be no window in which to discover you missed a deployment. See Rotate an API key. The tenant limit is 50 active keys. Revoked and expired keys do not count against it; exceeding it is a 400.

What a failed authentication tells you

Nothing useful. Missing, malformed, unknown, wrong secret, revoked and expired all produce the same 401 with the same body. Distinguishing them would tell an attacker which part of a forged credential to fix next.

Rate limiting applies per credential

Request rate is counted per credential — an API key by its own ID, a browser session by user ID — never per organisation. See Errors and limits for the headers and the documented failure behaviour.