API keys
A machine credential is presented in theAuthorization header:
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 anHttpOnly 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.
Why an API key is never accepted from a cookie
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 theAuthorization 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.
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
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:- Create the replacement, with the same scopes.
- Deploy it everywhere the old one is used.
- Revoke the old one, once nothing is using it.
400.
What a failed authentication tells you
Nothing useful. Missing, malformed, unknown, wrong secret, revoked and expired all produce the same401 with the same body. Distinguishing them would tell
an attacker which part of a forged credential to fix next.

