Flows
Sequence diagrams for the paths that are worth tracing end to end.
Most endpoints are a straight line: validate, read, write, respond. The flows collected here are the
ones that are not — they cross services, branch on stored state, or deliberately answer the same way
down several different paths.
| Flow | Why it is worth tracing |
|---|
| Login | Five distinct failure causes, one answer — and a sixth outcome that is not a token at all. |
| Two-factor authentication | Setup, confirmation, and the challenge-token round trip that completes a gated login. |
| Google Sign-In | Verification before anything else, then sign-up or sign-in down the same endpoint. |
| Person onboarding | Creation, the verification email, and the resend path — including what happens with no mail credentials. |
| Password recovery | An endpoint whose whole design is about what it must not reveal. |
| Scope permission claims | How a permission defined in a scope ends up inside a caller’s JWT. |
| Audit logging | One decorator, every write, no handler changes. |
| Application authentication | Issue a secret, exchange it for a token, and re-check that token live: what a consumer such as Cerberus has to do. |
Each page cites the use case (UC-…) and alternative flows (AF-…) it implements. The authoritative
descriptions live in the
Use Case Specification Document
.
1 - Login
UC-11 — password authentication, the uniform rejection, and the branch into a two-factor challenge.
POST /api/auth/login — anonymous, rate-limited.
The sequence
sequenceDiagram
autonumber
actor C as Client
participant AC as AuthController
participant M as CommandMediator
participant AH as AuditingCommandHandler
participant H as LoginCommandHandler
participant V as LoginCommandValidator
participant PR as Person repository
participant TS as PersonAuthTokenService
participant TI as JwtAuthTokenIssuer
participant DB as PostgreSQL
C->>AC: POST /api/auth/login {email, password, scopeId?}
AC->>M: ExecuteCommandAsync(LoginCommand)
M->>AH: HandleAsync
AH->>H: HandleAsync
H->>V: ValidateAsync
alt invalid shape (AF-11f)
V-->>H: errors
H-->>C: 400 validation errors
end
H->>PR: find by the lookup the role implies
PR->>DB: SELECT person + role + scope navigations
DB-->>PR: person or null
Note over H: AF-11a unknown · AF-11b wrong password<br/>AF-11c deleted person · AF-11d/e scope not eligible<br/>locked out (FR-AU-09)<br/>→ all answer "invalid credentials"
H->>H: Hash.TextMatches(password, hash, salt)
H->>TS: TryBuildSubject(person)
H->>DB: SELECT active TwoFactorAuth for person
alt 2FA active (AF-11g)
H->>H: retire live email codes, issue + mail a fresh one
H->>H: issue challenge token (MfaPending, ~5 min)
H-->>C: 200 {requiresTwoFactor, challengeToken, availableMethods}
else no 2FA
H->>TS: IssueAsync(subject)
TS->>TI: IssueAsync
TI->>DB: SELECT flagged scope permissions
TI-->>TS: signed JWT + expiry
H-->>AH: DataOutput{token, expiresAt, emailVerified}
AH->>DB: INSERT AuditLog(LoginCommand)
AH-->>C: 200 {token, expiresAt, emailVerified}
endWhich lookup, and why
Person has no scope column, so how the person is found depends on the role being sought:
| Role | Found by |
|---|
User | Email within the scope named by the request — the same address may exist as a User in several scopes. |
ScopeAdmin / SystemAdmin | Email alone — those addresses are unique system-wide. |
The lookup deliberately omits an !IsDeleted filter. AF-11c exists to reject a logically deleted
person, so the person has to be found before they can be rejected.
One answer for five causes
AF-11a (unknown email), AF-11b (wrong password), AF-11c (deleted person), AF-11d (a User whose
scope is logically deleted) and AF-11e (a ScopeAdmin with no live owned scope) all return the same
invalid credentials error.
The checks still run in the specification’s order, so the code reads against UC-11 line by line —
only the answer is uniform. An anonymous endpoint that distinguished them would be a directory of
which addresses are registered and which accounts still exist.
The answer has to be uniform in time as well as in text. AF-11a used to return without hashing
anything, since there was no stored hash to compare against — so “no such account” came back in
single-digit milliseconds while every other rejection paid a full Argon2id verification (600 MB / 16
threads) and took hundreds. That gap answered the question the shared message refuses, and varying
the scope id turned it into a per-scope directory. The handler now verifies the submitted password
against a decoy hash — of a random secret generated once per process, belonging to nobody — and
discards the result. A lockout takes the same path, so it is not detectable either.
The lockout counts attempts as they start, not as they fail. Each attempt reserves itself in
failed_login_attempts — one conditional UPDATE, refused once the account is locked or ten
attempts are already counted — before the password is derived; a wrong password leaves the
reservation standing, a right one clears the count. The attempt that reaches ten also sets
locked_out_until and resets the count in that same UPDATE, so a sign-in cut short at the limit
still locks the account for the usual fifteen minutes, and it unlocks by itself. A right password on
the tenth attempt still signs in and clears the lock. Counting after the derivation let ten
simultaneous wrong passwords all read a count of zero and all write one, so the account never locked
(FR-AU-09).
Scope eligibility
PersonAuthTokenService.TryBuildSubject is the single place the eligibility rules live, shared with
two-factor verification
so a gated login ends exactly like a direct one:
flowchart LR
P[Person] --> R{Role}
R -->|User| U{Their scope deleted?}
U -->|Yes| X[Not eligible — AF-11d]
U -->|No| T["Subject: personId, roleId,<br/>scopeId"]
R -->|ScopeAdmin| A{Any live owned scope?}
A -->|No| X2[Not eligible — AF-11e]
A -->|Yes| T2["Subject: personId, roleId,<br/>ownedScopeIds"]
R -->|SystemAdmin| T3["Subject: personId, roleId<br/>— no scope claims"]What the token carries
Claims are written by IdentityUserMapper, the same class that reads them back on validation:
| Claim | Present for |
|---|
id | Everyone — the person’s PublicId |
roleId | Everyone |
scopeId | User only |
ownedScopeIds | ScopeAdmin only, comma-separated |
scopePermissions | Anyone acting within a scope that has flagged permissions (details
) |
mfaPending | Challenge tokens only |
subjectRightsOnly | Subject-rights-only tokens only — a restricted identity’s (NFR-24) |
sst | Every person token — full, challenge and subject-rights-only — the person’s security stamp (FR-AU-11). Not on a Google User’s token |
The claims are omitted rather than emitted empty, so a token never suggests a scope association the
person does not have. Every value is a PublicId — an internal id never reaches a token (NFR-15),
except sst, a random value that means nothing outside Heimdall.
Revocation: when a token stops working
A person token is refused, with 401 and “This token has been revoked. Sign in again.”, from the
first request after any of these, whoever made the change and whichever token made it:
| Event | Use case |
|---|
| Password reset | UC-13 |
| Email change, by the person or an administrator | UC-08 |
| Two-factor authentication confirmed | UC-37 |
| Two-factor authentication disabled | UC-39 |
| Recovery codes regenerated | UC-40 |
Each replaces the person’s security stamp, and ActorLivenessFilter, which already reads the
person’s row on every request, refuses a token whose sst is missing or no longer matches. The
request that made the change still succeeds, but its token is revoked with the rest, so a client
signs in again after it. A token issued before this mechanism existed carries no sst and is refused
the same way. A 2FA challenge token outdated by such a change is refused at
POST /api/auth/2fa/verify as an invalid challenge.
Consumers validating person tokens offline do not see a revocation: they still accept the token
until it expires. Heimdall itself refuses it.
Audiences: what a consumer must pin
Heimdall issues three kinds of person token, signed with the same keys under the same issuer. Only
the full token is a sign-in, and the audience says which kind a token is:
| Token | aud |
|---|
| Full authentication token | HEIMDALL_AUTH_TOKEN_AUDIENCE, exactly as configured |
2FA challenge token (carries mfaPending) | HEIMDALL_AUTH_TOKEN_AUDIENCE + .2fa-challenge |
Subject-rights-only token (carries subjectRightsOnly) | HEIMDALL_AUTH_TOKEN_AUDIENCE + .subject-rights |
With HEIMDALL_AUTH_TOKEN_AUDIENCE=heimdall, those are heimdall, heimdall.2fa-challenge and
heimdall.subject-rights. The two derived audiences need no configuration of their own, and start-up
logs all three.
A consumer that accepts person tokens must validate the audience, pinned to
HEIMDALL_AUTH_TOKEN_AUDIENCE exactly. It then refuses challenge tokens, subject-rights-only
tokens and application tokens
with no knowledge of Heimdall’s
claims. A consumer that skips the audience check would take a challenge token, which a password
alone obtains, for a full sign-in. Set HEIMDALL_AUTH_TOKEN_AUDIENCE (it defaults to empty), so
there is an audience to pin.
Inside Heimdall every bearer token must name HEIMDALL_AUTH_TOKEN_ISSUER as its issuer and exactly
one of these three audiences (NFR-30); anything else is refused with 401 “Invalid token”, as a bad
signature is. IdentityUserMapper then holds each token to its own audience: it maps a token only
when its audience and its mfaPending and subjectRightsOnly claims agree, and refuses one that
carries either claim under the wrong audience or one of the derived audiences without its claim.
The 2FA branch
When the person has an active two-factor configuration, login does not return an authentication
token at all. It returns a short-lived challenge token carrying mfaPending under its own
audience
. Only POST /api/auth/2fa/verify and the email-code
resend accept it, as a body field; MfaPendingGuardFilter rejects it as a bearer token everywhere,
and a consumer pinning the person audience refuses it.
If the Email method is enabled, login retires every live email code for that configuration and mails
a fresh one first — so only the code mailed for this attempt can complete it.
Continue at Two-factor authentication
.
2 - Two-factor authentication
UC-36 to UC-40 — setup, confirmation, the challenge-token round trip, and recovery codes.
Two-factor authentication is opt-in, per person, and available to User, ScopeAdmin, and
SystemAdmin alike. It is not available to Google Users, who are already subject to Google’s own
account security.
Every 2FA endpoint acts on the caller themselves — the person comes from the bearer token, never from
a path or body — so no caller can reach another person’s configuration.
The lifecycle
stateDiagram-v2
[*] --> None: no TwoFactorAuth row
None --> Pending: UC-36 enable<br/>(row created, IsActive = false)
Pending --> Pending: UC-36 again<br/>(AF-36d — selection overwritten)
Pending --> Active: UC-37 confirm<br/>(every selected method proven)
Active --> Active: UC-40 regenerate<br/>(ten fresh recovery codes)
Active --> None: UC-39 disable
Active --> Active: UC-38 verify<br/>(completes a gated login)Setup — UC-36 and UC-37
sequenceDiagram
autonumber
actor P as Person (authenticated)
participant AC as AuthController
participant EH as EnableTwoFactorAuthCommandHandler
participant CH as ConfirmTwoFactorAuthCommandHandler
participant TP as ITotpSecretProtector
participant ES as ITwoFactorEmailSender
participant DB as PostgreSQL
P->>AC: POST /api/auth/2fa/enable {methods, currentPassword}
AC->>EH: HandleAsync
EH->>DB: SELECT TwoFactorAuth for caller
alt already active (AF-36a)
EH-->>P: rejected — configuration untouched
end
alt Email selected and the account-email budget would refuse (AF-36f, AF-36g)
EH-->>P: 429 — nothing changed or sent, no password check
end
alt currentPassword missing or wrong (AF-36e)
EH-->>P: 401 — nothing created, no account email charged
end
opt Email selected
EH->>DB: reserve one account email (FR-PE-16) — a burst gets one
end
EH->>DB: UPSERT pending row (IsActive = false)
Note over EH,DB: AF-36d — re-initiating overwrites<br/>the pending selection entirely
opt App selected
EH->>TP: Protect(fresh TOTP secret)
TP-->>EH: encrypted secret
EH->>DB: store TotpSecretEncrypted
EH-->>P: secret returned once, for enrolment
end
opt Email selected
EH->>DB: retire outstanding codes, INSERT fresh 6-digit code (hash + salt, 10 min)
EH->>ES: SendAsync(email, code)
end
P->>AC: POST /api/auth/2fa/confirm {appCode?, emailCode?}
AC->>CH: HandleAsync
CH->>DB: SELECT pending row
CH->>DB: charge the re-authentication budget (FR-AU-13)
CH->>CH: check appCode if AppEnabled (AF-37b)
CH->>CH: check emailCode if EmailEnabled (AF-37c)
CH->>DB: replace the person's security stamp
CH->>DB: INSERT 10 recovery codes (SHA-256 digests only)
CH->>DB: UPDATE IsActive = true
CH->>DB: mark the confirming email code Used
CH-->>P: 200 — ten recovery codes, shown exactly onceRecovery codes are 80 bits (FR-2F-20): ten random bytes written as sixteen characters of
Crockford’s base32 — the digits and the uppercase letters without I, L, O and U — and shown as
XXXX-XXXX-XXXX-XXXX. A code is accepted with or without its hyphens, with spaces, and in any case,
and an O, I or L typed in one reads as 0, 1 and 1. Codes of the shorter XXXX-XXXX shape issued
before keep working until they are used or regenerated; regenerating (UC-40) is how a person gets
the stronger ones.
Enabling needs the password (FR-2F-18): adding a second factor requires proof of identity,
as removing one does, and a bearer token alone is not that proof. Confirming
revokes every token the person holds, the confirming one included (FR-AU-11): the client keeps
the recovery codes, then signs in again, now through UC-38.
IsActive is what separates a confirmed configuration from one still pending. A row with
IsActive = false means UC-36 created it and UC-37 has not yet activated it — and only an active
configuration gates a login.
The order of the recovery-code and activation writes is deliberate. The repository layer exposes
no transaction, so ordering is what stands in for one, and the dangerous half-write is “two-factor
is active but the caller holds no recovery codes” — which locks someone out of their own account on
the strength of a request that reported failure. Writing the codes first (in one insert, not ten)
leaves only harmless failure modes: codes stored against a configuration that never activated, which
the next attempt replaces, and which authenticate nothing while IsActive is false. UC-40 orders
its writes the same way and for the same reason, creating the replacements before deleting the set
they replace, so a failure leaves the caller with the codes they already hold rather than none at
all.
Confirmation requires proof of every method selected at setup: an appCode if AppEnabled, an
emailCode if EmailEnabled, both if both. A missing or wrong code answers 401 “The password or
code is incorrect.”, whichever it was, and is charged to the re-authentication budget below.
Completing a gated login — UC-38
sequenceDiagram
autonumber
actor C as Client
participant AC as AuthController
participant VH as VerifyTwoFactorAuthCommandHandler
participant CT as JwtTwoFactorChallengeTokenIssuer
participant FV as ITwoFactorFactorVerifier
participant TS as PersonAuthTokenService
participant DB as PostgreSQL
Note over C: login answered requiresTwoFactor<br/>with a challenge token
C->>AC: POST /api/auth/2fa/verify {challengeToken, code | recoveryCode}
AC->>VH: HandleAsync
VH->>CT: validate signature, expiry, challenge audience + mfaPending claim
alt invalid (AF-38a)
CT-->>VH: rejected
VH-->>C: 401 challenge invalid
end
VH->>DB: SELECT person + active TwoFactorAuth
opt app code or recovery code (FR-2F-17)
VH->>DB: charge one guess to the challenge (UPDATE … WHERE attempts < 5)
alt no guesses left, or redeemed meanwhile
VH-->>C: 401 challenge invalid
end
end
VH->>DB: charge one failure to the account (FR-AU-12)
VH->>FV: TOTP? live email code? unused recovery code?
alt none matches, or the account's second factor is locked (AF-38b / AF-38c)
FV-->>VH: no
VH->>DB: at the fifth guess, clear the challenge
VH-->>C: 401 factor invalid
end
VH->>DB: clear the account's second-factor failures
VH->>TS: TryBuildSubject(person)
alt scope no longer eligible
VH-->>C: 401 scope no longer eligible
end
VH->>DB: redeem the challenge (UPDATE … WHERE challenge_id = token's)
VH->>DB: spend the recovery code (UPDATE … WHERE NOT used) + UsedAt
VH->>DB: spend the email code (UPDATE … WHERE NOT used)
VH->>TS: IssueAsync(subject)
TS-->>VH: full authentication token
VH-->>C: 200 {token, expiresAt, emailVerified}Three details in that diagram carry real weight:
AF-38b and AF-38c answer identically. A wrong code and an already-used recovery code both return
factor invalid, 401 — the same reasoning that collapses UC-11’s five rejections into one.
Scope ineligibility gets its own message. If the challenge token and the second factor were both
valid but the person’s scope eligibility no longer holds, the answer is
scope no longer eligible, not challenge invalid. Collapsing that case would misdescribe what
actually happened — nothing the caller supplied was wrong.
Nothing can be replayed. The redeemed recovery code and the redeemed email code are both marked
used before the token is issued. An app code is single-use too, though it has no row to mark: the
time step it was accepted at is recorded on TWO_FACTOR_AUTH.last_totp_time_step_used, and a code
from that step or earlier is refused (RFC 6238 §5.2). Without it the ±1-step tolerance meant an
observed code stayed usable for up to ninety seconds, across verify, disable, and regenerate alike.
A wrong email code costs the code. Five wrong guesses retire it (failed_attempts), so the
million values a six-digit code can take are not a million attempts — guessing again costs a fresh
login, which is rate limited and mails the account holder a code they did not ask for.
A challenge allows five guesses with an app code or a recovery code (FR-2F-17). Neither has a
row of its own to count on, so they are counted on the challenge (challenge_attempts), and at five
the challenge is cleared: its token is refused as AF-38a from then on, and further guessing costs a
fresh password check. A login resets the count with the challenge.
The account allows ten failures, whatever the challenge (FR-AU-12). Every guess — app code,
email code or recovery code — is also counted on the person, and neither a new challenge nor a
correct password resets that count; only a second factor that checks out does. The tenth failure
locks second-factor verification for fifteen minutes, twice as long at each repeat, up to a day.
While it is locked every code is refused, the right one included, with the same factor invalid
answer, and the challenge’s own five still run out as usual — so the lock does not show.
All of it holds when requests arrive together. Redeeming the challenge, spending a code,
accepting a TOTP step and charging a guess are each one conditional UPDATE (IAtomicWrites), and
every guess is charged before it is compared. Of any number of simultaneous requests carrying the
same challenge or the same code, exactly one gets a token; a parallel burst of guesses gets exactly
the budget compared, no more.
The final token is issued by PersonAuthTokenService — the same service a direct login uses — so a
2FA-gated login ends with exactly the token a direct one would have produced.
Why the guard filter never fires here
MfaPendingGuardFilter runs on every controller action and rejects any identity carrying
MfaPending. It never trips on POST /api/auth/2fa/verify because that endpoint is
[AllowAnonymous] and reads the challenge token as a body field, not as an
Authorization: Bearer credential — so the pipeline never attaches an MFA-pending identity for that
call. The filter only ever fires when a challenge token is misused as a bearer token somewhere
else — exactly the case FR-2F-10 wants blocked.
Disable and regenerate — UC-39 and UC-40
Both require a valid second factor as well as authentication, and both reuse
ITwoFactorFactorVerifier, so “match against TOTP, or the current email code, or an unused recovery
code” is implemented in exactly one place.
Every check under a bearer token shares one budget and one answer (FR-AU-13). Enabling,
confirming and disabling two-factor authentication, regenerating recovery codes, an own email change
and a person’s erasure request are each charged to the account’s re-authentication budget before
anything is compared: five failures within fifteen minutes pause them for fifteen minutes. A wrong or
missing password, a wrong, missing or used code, and a pause all answer 401 “The password or code
is incorrect.” — so disabling never says which of its two credentials was wrong. The budget is kept
apart from the login lockout and the second-factor budget, so a stolen token cannot be used to lock
its owner out of signing in.
| UC-39 disable | UC-40 regenerate |
|---|
| Requires | Bearer token + second factor | Bearer token + second factor |
| Effect | Removes the configuration | Replaces all ten recovery codes |
| Response | Confirmation | The ten new codes, shown once |
| Afterwards | Every token revoked, the caller’s included: sign in again | Every token revoked, the caller’s included: keep the codes, then sign in again |
Storage of secrets
| Stored as | Returned |
|---|
| TOTP secret | Encrypted at rest via ASP.NET Core Data Protection | Once, at provisioning |
| Recovery codes | SHA-256 only — 80 random bits need no per-code salt or slow hash (FR-2F-20) | Once, when generated |
| Email codes | Hash + per-code salt — only 10⁶ possible values | Never; mailed |
That is NFR-16. See Domain model
.
3 - Google Sign-In
UC-24 to UC-29 — enabling the feature per scope, sign-up and sign-in down one endpoint, and administering Google Users.
Google Sign-In is off by default and enabled per scope. A Google identity is always
User-equivalent — it can never create or authenticate a Scope Admin or System Admin, whatever the
Google account is (FR-GO-04).
Enabling it — two independent switches
flowchart LR
A["PUT /api/scopes/{id}/google-signin<br/>UC-24 — per scope"] --> C{Both true?}
B["HEIMDALL_GOOGLE_CLIENT_IDS<br/>— per deployment"] --> C
C -->|Yes| D[Google sign-in works]
C -->|Scope off| E["403 — sign-in unavailable"]
C -->|No client IDs| F["401 — every token refused"]With no client IDs configured, the API registers a verifier that refuses every token and warns
once at start-up. This is not a convenience fallback: verification needs an audience to check against
(NFR-13), and a verifier with no configured client could only reject everything or trust
everything — so it rejects. Every other endpoint keeps working.
Sign-up and sign-in — UC-25
One endpoint, POST /api/auth/google, does both. The first call for a given Google account in a
given scope creates the Google User; every later one authenticates it.
sequenceDiagram
autonumber
actor C as Client
participant AC as AuthController
participant H as GoogleSignInCommandHandler
participant GV as IGoogleIdTokenVerifier
participant G as Google
participant TI as JwtAuthTokenIssuer
participant DB as PostgreSQL
C->>AC: POST /api/auth/google {idToken, scopeId, nonce?}
AC->>H: HandleAsync
H->>GV: VerifyAsync(idToken)
GV->>G: signature, issuer, audience, expiry (NFR-13)
alt unverifiable or absent (AF-25a)
GV-->>H: null
H-->>C: 401 google authentication failed
end
G-->>GV: payload {sub, email, email_verified, name, picture, nonce}
alt nonce sent and the token's differs or is absent (AF-25g)
H-->>C: 401 google authentication failed
end
H->>DB: SELECT scope WHERE PublicId = scopeId<br/>AND NOT IsDeleted AND GoogleSignInEnabled
alt missing, deleted, or disabled (AF-25b)
H-->>C: 403 google sign-in unavailable
end
H->>DB: SELECT GoogleUser WHERE ScopeId AND GoogleId = sub
alt not found — sign-up (FR-GO-09)
alt email_verified is not true (AF-25f)
H-->>C: 403 sign-up requires a verified email address
end
H->>DB: is the email free among the scope's Google Users AND Users?
alt taken (AF-25c)
H-->>C: 409 email already registered
end
H->>DB: INSERT GoogleUser from the verified claims
else found but deleted (AF-25d)
H-->>C: 401 google authentication failed
end
H->>TI: IssueAsync(googleUser.PublicId, User role, scope.PublicId)
TI->>DB: SELECT flagged scope permissions
TI-->>H: signed JWT + expiry
H-->>C: 200 {token, expiresAt, emailVerified}Verification comes first, before the scope is even read. An unverified caller learns nothing
about which scopes exist. It is also what answers a request that omitted the token entirely — which
is why UC-25 needs no 400 flow at all.
AF-25b answers alike for three different situations — the scope is missing, logically deleted, or
has the feature switched off. The alternative flow names all three as one outcome, so one filter
answers for all three.
A nonce binds the token to the client’s own sign-in attempt (FR-GO-20). nonce is optional: a
client that sent one to Google when asking for the ID token sends the same value here, and the
token’s nonce claim must equal it exactly; a token with another nonce, or none, answers as an
unverifiable token does. The nonce is a client-side check that the token it presents was issued for
its own sign-in request; whether a token is accepted rests on its signature, issuer, audience and
expiry (NFR-13). A request without nonce compares nothing, as before.
Only an address Google vouches for can sign up (FR-GO-07, AF-25f). A first sign-in whose
token does not carry email_verified: true — false, or absent — creates nothing and answers 403
“Google sign-up requires a verified email address.”. This is checked before the address is compared
with the scope’s. A Google User that already exists signs in whatever the claim says; FR-GO-19 keeps
its stored flag current.
AF-25c is named separately, on purpose. Being told the address is already registered reveals
something — but the caller has proved to Google that the address is theirs, and Google vouches for
it, so it tells them only about themselves. Uniqueness is checked jointly against the scope’s Google
Users and its password Users.
Signing out — UC-26
POST /api/auth/google/sign-out ends the client’s session and nothing else. Heimdall keeps no
Google session, and its tokens are stateless signed JWTs with an expiry, so there is nothing to
revoke: a copy of the token kept elsewhere stays valid until it expires (FR-GO-18). What the
endpoint does is check — it confirms the token still names a live Google User and then answers the
success that tells the client to drop the token.
That check is the substance. Authentication reads no database per request, so a token outlives the
account it names; one naming a deleted or unknown Google User is refused here.
Administering Google Users — UC-27 to UC-29
All four endpoints are nested under the scope the Google User belongs to (FR-GO-06), and each
refuses a Google User reached through the wrong scope.
| Endpoint | Use case | Who |
|---|
GET /api/scopes/{scopeId}/google-users | UC-27 | System Admin, or an owner of the scope |
GET /api/scopes/{scopeId}/google-users/{id} | UC-27 | The above, or the Google User themselves |
DELETE /api/scopes/{scopeId}/google-users/{id} | UC-28 | System Admin, or an owner of the scope |
DELETE /api/scopes/{scopeId}/google-users/{id}/hard | UC-29 | System Admin only |
The by-id read is the one that admits a Google User, and it is why that endpoint carries no
[RoleRequirement]: a Google User’s token is User-role, so anything strong enough to keep other
Users out would lock out the actor UC-27 grants. The listing admits none of them.
The logical delete is idempotent — repeating it answers 200 with alreadyDeleted: true and
writes nothing — and it is honoured everywhere it matters: a deleted account cannot sign in, cannot
sign out, and is absent from reads unless includeDeleted=true. The hard delete has no idempotent
path; a second call is a 404. Neither cascades, because a Google User owns nothing.
Testing without Google
A third verifier exists for the functional suite, which cannot override a DI registration and must
still reach the flows behind verification. It is guarded twice — never in Production, and never
without an explicitly set signing secret — and is checked before the real verifier so a test
environment cannot accidentally run both.
4 - Person onboarding
UC-06, UC-14 and UC-15 — creating a person, the verification email, and replacing a lost link.
Creating a person is one use case (UC-06) with three paths, decided by which endpoint is called
rather than by a field in the body.
| Endpoint | Path | Creates |
|---|
POST /api/persons | b | A ScopeAdmin or SystemAdmin with no scope |
POST /api/scopes/{scopeId}/persons | a | A User in that scope, with a SCOPE_USER row |
POST /api/scopes/{scopeId}/owners | c | A brand-new ScopeAdmin directly as a co-owner, with a SCOPE_OWNER row |
Creating a User — UC-06 path a
sequenceDiagram
autonumber
actor A as Actor (SystemAdmin or ScopeAdmin)
participant PC as PersonController
participant AH as AuditingCommandHandler
participant H as CreateUserCommandHandler
participant OC as IScopeOwnershipChecker
participant EV as IEmailVerificationService
participant ES as IEmailVerificationSender
participant DB as PostgreSQL
A->>PC: POST /api/scopes/{scopeId}/persons {name, email, password}
PC->>AH: ExecuteCommandAsync
AH->>H: HandleAsync
H->>H: validate input (AF-06d)
H->>DB: SELECT scope — must exist and not be deleted (AF-06b)
H->>OC: does the actor own this scope? (AF-06e)
Note over OC: a SystemAdmin bypasses this check
H->>DB: is the email free among the scope's Users? (AF-06a, case-insensitive)
H->>DB: INSERT person (Argon2id hash + salt) + SCOPE_USER row
H->>EV: IssueAndSendAsync(person)
EV->>DB: INSERT EmailVerificationToken (48 chars, time-limited)
EV->>ES: SendAsync(email, token)
Note over EV,ES: a send failure does not undo the person —<br/>the token is persisted and can be re-sent (UC-15)
H-->>AH: DataOutput{person}
AH->>DB: INSERT AuditLog(CreateUserCommand)
AH-->>A: 201 person createdEmail uniqueness is scoped to the role: a User’s address must be unique within their scope
(compared case-insensitively, LOWER() in SQL), while a ScopeAdmin’s or SystemAdmin’s is unique
system-wide. The same address can therefore be a User in several different scopes — which is exactly
why login needs a scope identifier to find a User.
Verifying the address — UC-14
sequenceDiagram
autonumber
actor P as Person
participant FE as Front-end verification page
participant AC as AuthController
participant H as VerifyEmailCommandHandler
participant DB as PostgreSQL
Note over P: opens the emailed link<br/>?token=…
P->>FE: click link
FE->>AC: POST /api/auth/verify-email {token}
AC->>H: HandleAsync
H->>DB: SELECT token, its person and their scopes
alt unknown (AF-14c), or the person may no longer be acted on (AF-14d)
H-->>FE: 400 — invalid token
end
alt expired (AF-14a) / spent (AF-14b)
H-->>FE: 400 — each rejection named distinctly
end
H->>DB: UPDATE person.EmailVerified = true<br/>WHERE address unchanged AND not restricted / deleted / anonymised
H->>DB: mark every live token for this person Used
H-->>FE: 200The endpoint is anonymous — someone arriving from a link in their mail client holds no token of
any other kind.
Spending a token retires every other live token the person holds of that kind, so a verification link
left in an inbox stops working once the address is verified. An address that was already verified
answers 200 rather than an error: UC-14 defines no alternative flow for it, and the caller’s intent
has been satisfied either way. A person restricted, deleted or anonymised since the link was mailed,
or who can no longer sign in through their scope, gets 400 “Invalid token.” and nothing is written
(FR-EV-08), as UC-13 answers them.
Replacing a lost link — UC-15
POST /api/auth/resend-verification is authenticated and takes no body. The person is read from
the bearer token, so a caller can only ever ask for their own link.
flowchart TD
A[POST /api/auth/resend-verification<br/>bearer token only] --> B{Already verified?}
B -->|Yes| C["400 — AF-15a<br/>a link mailed to it could do nothing"]
B -->|No| G{Account-email budget<br/>reserves a send?}
G -->|"No — cooldown or daily limit"| H["429 — AF-15b / AF-15c<br/>nothing sent; the last link still works"]
G -->|Yes| D[Retire outstanding tokens]
D --> E[Issue + mail a fresh one]
E --> F[200 — only the newest link works]Note the asymmetry with UC-14: verifying an already-verified address answers 200, but resending
to one answers 400. Verification’s job is done either way; a resend would mail a link that could
do nothing when clicked.
A resend is charged to the person’s account-email budget (FR-PE-16), which it shares with a
two-factor setup code and the notice an own email change sends: one per
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five minutes by default) and five per day-long window from the first, reserved by one
write before anything is sent. Inside the cooldown the answer is 429 “An email of this kind was
sent to you recently. Try again in a few minutes.” (AF-15b); once the window’s five are spent, 429 “You have
reached the limit for account emails. Try again later.” (AF-15c). Both carry Retry-After, the
seconds until a send would be accepted. Nothing is sent or retired
either way. A link an administrator sent, at creation or through UC-51, is not charged, so the
person may still ask once straight after. The endpoint is under the credential endpoints’ rate limit
too, whose own 429 says “Too many requests.” and carries a Retry-After header.
An administrator resending it — UC-51
A System Admin, or the owner of a User’s scope, can ask for the link on the person’s behalf with
POST /api/persons/{id}/verification-email. It is UC-15’s send — live links retired, a new one mailed
to the person’s own address — refused for a deleted or restricted person (409) or a verified address
(400). At most one such email goes to a person per HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS (five
minutes by default), counting any verification email sent to the person in the window, including
the one sent when the account was created; a request inside the window answers 429 and sends
nothing. The check is best-effort: simultaneous requests for the same person can both pass it.
Without mail credentials
With Mailgun unconfigured, the API registers logging senders instead: each token is written to the
log rather than emailed, with a single warning at start-up. That keeps local runs and the functional
suite working without credentials and off the network.
It is a supported mode, not a broken one — outside Production. An unconfigured Production
deployment refuses to start outright, because the fallback would print verification tokens, password
reset tokens, and 2FA codes in plaintext to the logs, which is an account-takeover primitive for
anyone who can read them.
Both emails carry a link built from HEIMDALL_EMAIL_VERIFICATION_URL /
HEIMDALL_PASSWORD_RESET_URL, with the token appended as a token query parameter. If no link is
configured the email carries the bare token instead, which still works — the link is only a
convenience wrapper around it.
A scope may override both links for its own Users (PUT /api/scopes/{id}/email-links), so a
client system’s users are sent to that system’s screens; administrators, and a scope with no link
set, use the global ones.
5 - Password recovery
UC-12 and UC-13 — an endpoint whose whole design is about what it must not reveal.
Two anonymous, rate-limited endpoints: one asks for a link, the other spends it.
Requesting a link — UC-12
sequenceDiagram
autonumber
actor C as Anonymous caller
participant AC as AuthController
participant H as PasswordRecoveryCommandHandler
participant Q as PasswordRecoveryQueue
participant W as PasswordRecoveryDispatcher
participant PR as PasswordRecoveryProcessor
participant PS as IPasswordResetService
participant S as IPasswordResetSender
participant DB as PostgreSQL
C->>AC: POST /api/auth/password-recovery {email, scopeId?}
AC->>H: HandleAsync
H->>H: validate input shape (NFR-10)
Note right of H: the only rejection this endpoint ever issues
H->>Q: TryEnqueue(email, scopeId)
H-->>C: 200 — identical response, in the same time, either way
Q-->>W: next request (background, own DI scope)
W->>PR: ProcessAsync
PR->>DB: find by the lookup the role implies
alt person found and eligible
PR->>PS: issue token
PS->>DB: INSERT PasswordResetToken (time-limited)
PS->>S: SendAsync(email, token)
else nobody, deleted or restricted person, or deleted scope (AF-12a)
Note over PR,DB: no row written, no email sent
endEvery path returns the same success output. AF-12a — the address belongs to nobody — is not an
error flow but the absence of one: the handler issues no token and answers exactly as it would
have. A logically deleted person, and a User whose scope is deleted, are treated the same way.
The only thing that distinguishes the two paths is a row that does not get written and an email that
consequently never arrives — neither of which is visible to the caller.
The same answer, in the same time. While the lookup, the token insert and the Mailgun call ran on
the request, a registered address answered a Mailgun round trip later than an unknown one — a timing
difference that answered exactly what the uniform response refuses. So the handler only validates and
queues; everything that depends on the address happens afterwards, in PasswordRecoveryProcessor,
run by a hosted service in a DI scope of its own (never the finished request’s). The queue is in
memory and bounded: a durable outbox would keep every address anybody typed, registered or not, and
a response-time floor would have to outlast Mailgun’s slowest send. The cost is that a restart drops
whatever is still queued, which a person who receives nothing simply asks for again.
An outage must not become an oracle
A Mailgun failure here is logged, never surfaced. If a delivery error turned into a 500, the
difference between “your guess was a real account” and “it wasn’t” would be an HTTP status code.
An administrator sending the link — UC-52
POST /api/persons/{id}/password-reset lets a System Admin, or the owner of a User’s scope, have the
link mailed to the person’s own address. Eligibility is UC-12’s, from the same code — but because the
caller is authenticated, a refusal is reported instead of hidden: 409 for a deleted or restricted
person, or one whose account could not sign in. The send is not queued, since there is no anonymous
caller to protect from a timing difference. The same per-person cooldown as UC-51 applies (429), and is best-effort: simultaneous requests for
the same person can both pass it.
Setting the new password — UC-13
sequenceDiagram
autonumber
actor P as Person
participant FE as Front-end reset page
participant AC as AuthController
participant H as ResetPasswordCommandHandler
participant DB as PostgreSQL
P->>FE: opens the emailed link ?token=…
FE->>AC: POST /api/auth/password-reset {token, newPassword}
AC->>H: HandleAsync
H->>H: validate the new password's shape
H->>DB: SELECT token, its person and their scopes
alt unknown, or the person may no longer be acted on (AF-13c / AF-13e)
H-->>FE: 400 — invalid token
end
alt expired / already spent
H-->>FE: 400 — each rejection named
end
H->>DB: spend the token (UPDATE … WHERE NOT used AND unexpired)
alt a parallel request spent it first
H-->>FE: 400 — already used (AF-13b)
end
H->>DB: UPDATE person SET hash, salt, security stamp<br/>WHERE address unchanged AND not restricted / deleted / anonymised
alt the person changed since it was read
H-->>FE: 400 — invalid token
end
H->>DB: mark every other live reset token for this person Used
H-->>FE: 200Both endpoints are anonymous because someone arriving from a link in their mail client holds no
token of any other kind.
Spending a reset token retires every other live reset token the person holds. A second “forgot
password” click cannot be replayed after the first has already changed the password.
The account is checked again when the link is followed (FR-PR-08). A link lives for an hour,
and the person may be restricted, deleted or anonymised, or lose the scope they sign in through,
while it waits in a mailbox. UC-12’s rule is run again, and a person it no longer admits gets exactly
the unknown-token answer, 400 “Invalid token.”, with nothing written — the token stays as it was,
and a restricted record is preserved. The password itself is written by one conditional UPDATE,
together with a fresh security stamp that revokes every earlier session, which repeats those checks
and requires the address the person was read with. After the row is created nothing else writes the
password columns, so an edit saved from an older copy of the person cannot put the old password back.
An email change retires the links (FR-PE-17). Whoever changes a person’s address, every live
reset and verification link they hold is retired, before the new address is saved and again straight
after.
Following one afterwards answers 400 “Token already used.”.
Why login costs what it costs
Password verification is Argon2id — by this codebase’s hashing library defaults, 600 MB and 16
threads per verification. That is deliberate for a stored credential, and it is also precisely why
these endpoints are rate-limited: an unthrottled burst against /login is a memory and CPU
exhaustion vector before it is a brute-force one. See
Operations
.
6 - Scope permission claims
UC-31 to UC-35 and FR-AU-08 — how a permission defined in a scope ends up inside a caller JWT.
A scope permission is a named permission defined inside one scope. It has no owner of its own —
owning the scope is the whole of the authorization to manage it — and it is a leaf in the data model:
nothing carries a foreign key to it.
Its one moving part is the IncludeAsJwtClaim flag.
From definition to claim
sequenceDiagram
autonumber
actor SA as Scope Admin
participant SPC as ScopePermissionController
participant CH as CreateScopePermissionCommandHandler
participant DB as PostgreSQL
actor U as User in that scope
participant AC as AuthController
participant LH as LoginCommandHandler
participant TI as JwtAuthTokenIssuer
SA->>SPC: POST /api/scopes/{scopeId}/permissions<br/>{name, description, includeAsJwtClaim: true}
SPC->>CH: HandleAsync
CH->>DB: is the actor an owner of this scope?
CH->>DB: INSERT ScopePermission
CH-->>SA: 201
Note over U: later — an ordinary login
U->>AC: POST /api/auth/login
AC->>LH: HandleAsync
LH->>TI: IssueAsync(subject)
TI->>DB: SELECT DISTINCT name<br/>WHERE scope in subject's scopes<br/>AND NOT scope.IsDeleted<br/>AND NOT permission.IsDeleted<br/>AND IncludeAsJwtClaim
DB-->>TI: permission names
TI->>TI: fold into the scopePermissions claim
TI-->>U: JWT carrying the permission namesWhich permissions a caller claims
flowchart TD
S[Subject being issued a token] --> R{Role}
R -->|User| A["The permissions of<br/>the one scope they belong to"]
R -->|ScopeAdmin| B["The union over<br/>every scope they own"]
R -->|SystemAdmin| C["None — they belong<br/>to no scope"]
A & B --> F["Filtered: scope not deleted,<br/>permission not deleted,<br/>IncludeAsJwtClaim set"]
F --> G["Distinct names →<br/>scopePermissions claim (JSON array)"]The fold happens at issuance, reading the database once. A permission created, flagged, or
deleted after a token was issued does not change that token — the holder picks up the change on their
next login. This is the same trade the rest of the authentication design makes: no database read per
request, in exchange for claims that are a snapshot rather than a live view.
A System Admin carries no scopePermissions claim at all. They belong to no scope, so there is
nothing to fold — and nothing they need it for, since their authority is not scope-derived.
Managing them — UC-31 to UC-35
| Endpoint | Use case | Who |
|---|
POST /api/scopes/{scopeId}/permissions | UC-31 | System Admin, or an owner of the scope |
GET /api/scopes/{scopeId}/permissions/{id} | UC-32 | Same |
GET /api/scopes/{scopeId}/permissions | UC-32 | Same |
PUT /api/scopes/{scopeId}/permissions/{id} | UC-33 | Same |
DELETE /api/scopes/{scopeId}/permissions/{id} | UC-34 | Same |
DELETE /api/scopes/{scopeId}/permissions/{id}/hard | UC-35 | System Admin only |
The hard delete is System-Admin-only for the same reason it is on applications and Google Users: a
Scope Admin may retire a permission in a scope they own, but never purge it.
Deletion behaviour
Both deletions are simple, because a permission is a leaf:
- Logical delete (UC-34) flips only its own
IsDeleted flag. Nothing cascades. It stops being
folded into new tokens immediately. - Hard delete (UC-35) removes the row. Nothing cascades.
The interesting case is the scope’s deletion:
| Scope operation | Effect on its permissions |
|---|
| Logical delete (UC-04) | None. They keep whatever IsDeleted state they had. |
| Hard delete (UC-05) | Purged, via the scope_permission → scope foreign key’s ON DELETE CASCADE. |
Not cascading the logical delete is deliberate. The permissions become unreachable anyway — the
listing endpoint gates on the scope’s IsDeleted, and the claim fold above excludes permissions of
deleted scopes — so nothing is exposed. Leaving them untouched means a restored scope recovers its
permission set unchanged, which cascading would have destroyed.
7 - Audit logging
NFR-09 — one decorator, every write, no handler changes.
NFR-09: all write operations shall produce audit log entries.
Rather than a line in every handler — forgettable, and duplicated forty times — this is a decorator
applied at registration.
How a handler gets audited
sequenceDiagram
autonumber
participant DI as DI container
participant M as CommandMediator
participant A as AuditingCommandHandler
participant H as Concrete handler
participant W as AuditLogWriter
participant AA as IActorAccessor
participant DB as PostgreSQL
M->>DI: resolve ICommandHandlerAsync<TCommand, TOutput>
DI-->>M: AuditingCommandHandler wrapping the concrete handler
M->>A: HandleAsync(command, cancellationToken)
A->>H: HandleAsync(command, cancellationToken)
H->>DB: the actual write
H-->>A: DataOutput{success, data}
alt success
A->>A: resolve target from output.Id / output.PublicId
A->>W: WriteAsync(commandTypeName, targetId)
W->>AA: ActorPersonId, ActorRole
AA-->>W: from the current request's IdentityUser
W->>DB: INSERT AuditLog
alt the insert fails
W--xA: exception
A->>A: log a warning — the request still succeeds
end
else failure
Note over A: nothing written — only successful writes are audited
end
A-->>M: the unchanged DataOutputRegistration is the whole mechanism:
services.AddScoped<THandler>();
services.AddScoped<ICommandHandlerAsync<TCommand, TOutput>>(provider =>
new AuditingCommandHandler<TCommand, TOutput>(
provider.GetRequiredService<THandler>(),
provider.GetRequiredService<IAuditLogWriter>(),
provider.GetRequiredService<ILogger<AuditingCommandHandler<TCommand, TOutput>>>()));
Every write handler in Startup.AddDependencies is registered with AddAuditedCommandHandler, so
adding a new command means adding one registration line and inheriting the audit trail.
Depend on the interface
The concrete THandler registration exists only so the decorator factory can resolve it. Resolving
THandler directly — in a handler, a service, or a test that then asserts on production wiring —
silently bypasses auditing. Always depend on ICommandHandlerAsync<TCommand, TOutput>.
What an entry records
| Column | Value |
|---|
PublicId | The entry’s own GUID |
ActorPersonId | The acting person’s PublicId, or null for an anonymous write |
ActorRole | The acting person’s role value, or null for an anonymous write |
Action | The command’s CLR type name, e.g. "CreateApplicationCommand" |
TargetId | Best-effort PublicId of the affected entity, or null if none could be resolved |
CreatedAt | UTC timestamp |
Three design choices are worth calling out.
ActorPersonId is not a foreign key. It is a bare PublicId, so an entry survives the hard
deletion of the person who made the write — which is exactly the case an audit trail exists for.
The table is append-only. Entries are never deleted. The only update a database trigger permits
is the retention pass clearing the actor attribution, and a target naming a person or Google User,
once due (NFR-21).
The target is resolved reflectively, from the output’s Id or PublicId property. Commands
whose output carries neither record a null target rather than failing.
Failures never fail the request
If writing the entry throws, the decorator logs a warning and returns the original result. The user’s
write already succeeded and committed; turning that into a 500 would misreport what happened and
invite a retry of an operation that already took effect.
The trade is explicit: audit-trail completeness is best-effort against a database failure, and the
warning in the log is the record that a gap exists.
Anonymous writes
IActorAccessor reads the current request’s IdentityUser, which is null on an anonymous request.
The anonymous write endpoints — login, password recovery, password reset, email verification, Google
sign-in, second-factor verification — therefore produce entries with a null actor. The Action
still names the command, so the entry records what happened even when it cannot record who.
Reading the trail — UC-48
GET /api/audit-log lists entries for a System Admin, newest first. Ties are broken by write order,
so the order is deterministic for a fixed set of rows. Every command writes to the trail, so entries
written between two page requests shift older ones and the next page can repeat some; to page a
stable snapshot, pin to to the time of your first request. It filters by from/to (UTC, inclusive; a
value without an offset is read as UTC), actorId, action, targetId and succeeded, combined with
AND. targetId is indexed (ix_audit_log_target_id), so “what happened to this entity?” does not scan
the table.
The listing reads AUDIT_LOG and nothing else. A cleared attribution stays cleared: actorId and
actorRole read null, and no join or neighbouring entry is used to put a name back. A cleared person
target reads targetId: null the same way, so filtering by targetId cannot find that entry. Listing is a read
and writes no entry of its own.
8 - Application authentication
UC-53 to UC-57 — credentials for a registered application, the token they buy, and the live re-check consumers make.
An application authenticates to Heimdall as itself: an administrator issues it a secret (UC-53), the
application exchanges the secret for a short-lived token (UC-56), and a consumer service checks that
token live (UC-57). This page is written for the people building that consumer. The authoritative
descriptions live in the
Use Case Specification Document
.
Who this is for
A service that must know which registered application is calling it, not which person. Cerberus
is the first. Such a service never sees a person’s token for this purpose and never holds a role:
an application token carries the application’s identity and nothing else.
Provisioning and rotation
A System Admin, or the Scope Admin who owns the application and still owns its scope, issues a
credential. The expiresAt in the body is optional; a value without an offset is read as UTC.
curl -X POST http://localhost:5177/api/scopes/$SCOPE_ID/applications/$APPLICATION_ID/credentials \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"expiresAt":"2030-01-01T00:00:00Z"}'
The answer is 201 Created:
{
"data": {
"id": "6f1c0d52-8f0e-4a43-9c52-2d6f6a9b1d11",
"secret": "hds_q3y0lV8mJ1k0d9yq5Qh2a8Xv7nTz4RbC6uWe1sPoKfM",
"createdAt": "2026-10-14T12:00:00Z",
"expiresAt": "2030-01-01T00:00:00Z"
},
"messages": ["Application credential issued. Store the secret now: it is never shown again."],
"errors": [],
"success": true
}
The secret appears in this response and nowhere else. Heimdall keeps only its SHA-256 hash, so no
later call, backup or log can return it. Put it in the consumer’s secret store now. A lost secret is
replaced, not recovered: issue another and revoke the lost one.
An application holds at most two live credentials (not revoked, not expired). That limit is what
makes rotation free of downtime:
- issue a second credential (
POST …/credentials); - deploy its secret to the application;
- revoke the first (
DELETE …/credentials/{credentialId}).
With two already live, a third is refused with 409 Conflict; revoke one first. GET …/credentials
lists them newest first with id, createdAt, expiresAt, revokedAt, lastUsedAt and live, so
you can see the new secret in use (lastUsedAt) before you revoke the old one.
Obtaining a token
The application exchanges a secret for a token. The endpoint is anonymous: the secret authenticates.
curl -X POST http://localhost:5177/api/applications/token \
-H "Content-Type: application/json" \
-d "{\"applicationId\":\"$APPLICATION_ID\",\"scopeId\":\"$SCOPE_ID\",\"secret\":\"$APPLICATION_SECRET\"}"
{
"data": {
"id": "0c9a3e7e-5a53-4a61-8e57-6b3f0e0d7a10",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"expiresAt": "2026-10-14T12:10:00Z"
},
"messages": ["Application token issued."],
"errors": [],
"success": true
}
Request a new token shortly before expiresAt and reuse it until then. Never request one per
call: the endpoint is rate-limited per client address like person sign-in, and each exchange is
audited.
Every refusal is the same 401 Unauthorized — “The application credentials were not accepted.” — so
the endpoint reveals nothing about which applications, scopes or credentials exist. When you see it,
check, in order:
- the
applicationId is the application the credential was issued for; - the
scopeId is that application’s scope; - the credential is live: not revoked, not past its
expiresAt, and the application and scope are
not deleted (GET …/credentials shows live); - the secret is exactly as issued. Surrounding whitespace is trimmed (a trailing newline from a
file, for one); a truncated or altered secret is not accepted.
A request that is missing a field, carries a secret over 100 characters, or is not readable JSON
(or carries an id that is not a GUID) is a 400 instead: the field’s message, or
“The request is malformed.” for a body that cannot be read.
The token
An application token is a JWT signed with HMAC-SHA256 under the same keys as person tokens, valid for
ten minutes by default (HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS).
| Claim | Value |
|---|
iss | HEIMDALL_AUTH_TOKEN_ISSUER, when one is configured |
aud | HEIMDALL_APPLICATION_TOKEN_AUDIENCE — never the person audience |
exp, iat, nbf | Expiry, issue time and not-before |
tokenKind | application |
id | The application’s identifier |
scopeId | The identifier of the application’s scope |
credentialId | The credential the token was issued from |
name | The application’s name |
There is no role claim. An application token never grants a role, and Heimdall’s own endpoints
refuse it: any token carrying tokenKind is refused where a person token is expected.
Validating offline
A consumer can validate the token without calling Heimdall. It must check all of these:
- the algorithm is HS256, and nothing else is accepted;
- the signature, against both signing keys while a rotation is in progress
(
HEIMDALL_AUTH_TOKEN_SECRET and HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS); - the issuer, when Heimdall is configured with one (Heimdall itself requires its own issuer, or none
when none is configured, and the application audience as the token’s only audience — NFR-30);
- the audience is the application audience, the value of
HEIMDALL_APPLICATION_TOKEN_AUDIENCE; - the lifetime, with zero clock skew;
tokenKind is "application".
If the consumer also validates person tokens, it must pin the person audience. Both kinds are
signed with the same keys. A consumer that checks the signature but not the audience would accept
an application token as a person’s. Heimdall refuses to start when the two audiences are equal for
exactly this reason. Pinning it also refuses Heimdall’s 2FA challenge and subject-rights-only tokens,
which carry audiences of their own (Login: audiences
).
Heimdall itself validates with zero skew, so instances whose clocks differ can refuse a freshly issued
token for the length of the drift. Keep clocks synchronised (NTP).
Checking live
Offline validation says the token was issued and has not expired. It cannot say the credential is
still wanted. For that, present the token to Heimdall:
curl http://localhost:5177/api/applications/self \
-H "Authorization: Bearer $APPLICATION_TOKEN"
{
"data": {
"applicationId": "0c9a3e7e-5a53-4a61-8e57-6b3f0e0d7a10",
"name": "Cerberus",
"scopeId": "3b1e5d46-1d3c-4f4e-8a35-0f6d6b6f8e22",
"credentialId": "6f1c0d52-8f0e-4a43-9c52-2d6f6a9b1d11",
"expiresAt": "2026-10-14T12:10:00Z"
},
"messages": ["Application token accepted."],
"errors": [],
"success": true
}
The answer is 200 OK only when the token validates (as above) and its credential is live and
its application and scope are live and match the token. Anything else is one 401 Unauthorized —
“The application token was not accepted.” — whatever failed.
Revocation policy
Revoking or expiring a credential, or deleting the application or its scope, takes effect at the
next live check for every token already issued. A token’s own exp is fixed when it is issued, so
it stays valid offline for up to its whole lifetime, ten minutes by default, after its credential
expires or is revoked. Only the live check reflects that at once. So a consumer re-checks before
every sensitive release, and treats the offline check as a fast path for everything else.
Status codes
| Endpoint | Status | When |
|---|
POST …/credentials | 201 | Credential issued; the secret is in the body |
| 400 | expiresAt is not in the future, is not a date-time, or the body is not JSON (AF-53c) |
| 401 / 403 | Anonymous; a User, or a Scope Admin who does not own the application or no longer owns its scope (AF-53b) |
| 404 | No live application with that id in the addressed, live scope (AF-53a) |
| 409 | Two live credentials already (AF-53d) |
GET …/credentials | 200 | The credentials, newest first; an empty list when there are none (AF-54c) |
| 401 / 403 / 404 | As above (AF-54a, AF-54b) |
DELETE …/credentials/{credentialId} | 200 | Credential revoked |
| 401 / 403 / 404 | As above, and 404 when the credential is not on this application (AF-55a to AF-55c) |
| 409 | Already revoked (AF-55d) |
POST /api/applications/token | 200 | Token issued |
| 400 | A field missing, the secret over 100 characters, or a body that is not readable (AF-56a) |
| 401 | Any other reason, always the same message (AF-56b) |
| 429 | Rate limit; the Retry-After header gives the seconds to wait |
GET /api/applications/self | 200 | Token valid and still live |
| 401 | Any reason, always the same message (AF-57a) |