This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

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.

FlowWhy it is worth tracing
LoginFive distinct failure causes, one answer — and a sixth outcome that is not a token at all.
Two-factor authenticationSetup, confirmation, and the challenge-token round trip that completes a gated login.
Google Sign-InVerification before anything else, then sign-up or sign-in down the same endpoint.
Person onboardingCreation, the verification email, and the resend path — including what happens with no mail credentials.
Password recoveryAn endpoint whose whole design is about what it must not reveal.
Scope permission claimsHow a permission defined in a scope ends up inside a caller’s JWT.
Audit loggingOne decorator, every write, no handler changes.
Application authenticationIssue 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}
    end

Which lookup, and why

Person has no scope column, so how the person is found depends on the role being sought:

RoleFound by
UserEmail within the scope named by the request — the same address may exist as a User in several scopes.
ScopeAdmin / SystemAdminEmail 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:

ClaimPresent for
idEveryone — the person’s PublicId
roleIdEveryone
scopeIdUser only
ownedScopeIdsScopeAdmin only, comma-separated
scopePermissionsAnyone acting within a scope that has flagged permissions (details )
mfaPendingChallenge tokens only
subjectRightsOnlySubject-rights-only tokens only — a restricted identity’s (NFR-24)
sstEvery 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:

EventUse case
Password resetUC-13
Email change, by the person or an administratorUC-08
Two-factor authentication confirmedUC-37
Two-factor authentication disabledUC-39
Recovery codes regeneratedUC-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:

Tokenaud
Full authentication tokenHEIMDALL_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 once

Recovery 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 disableUC-40 regenerate
RequiresBearer token + second factorBearer token + second factor
EffectRemoves the configurationReplaces all ten recovery codes
ResponseConfirmationThe ten new codes, shown once
AfterwardsEvery token revoked, the caller’s included: sign in againEvery token revoked, the caller’s included: keep the codes, then sign in again

Storage of secrets

Stored asReturned
TOTP secretEncrypted at rest via ASP.NET Core Data ProtectionOnce, at provisioning
Recovery codesSHA-256 only — 80 random bits need no per-code salt or slow hash (FR-2F-20)Once, when generated
Email codesHash + per-code salt — only 10⁶ possible valuesNever; 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.

EndpointUse caseWho
GET /api/scopes/{scopeId}/google-usersUC-27System Admin, or an owner of the scope
GET /api/scopes/{scopeId}/google-users/{id}UC-27The above, or the Google User themselves
DELETE /api/scopes/{scopeId}/google-users/{id}UC-28System Admin, or an owner of the scope
DELETE /api/scopes/{scopeId}/google-users/{id}/hardUC-29System 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.

EndpointPathCreates
POST /api/personsbA ScopeAdmin or SystemAdmin with no scope
POST /api/scopes/{scopeId}/personsaA User in that scope, with a SCOPE_USER row
POST /api/scopes/{scopeId}/ownerscA 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 created

Email 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: 200

The 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.

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.

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
    end

Every 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.

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: 200

Both 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 names

Which 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

EndpointUse caseWho
POST /api/scopes/{scopeId}/permissionsUC-31System Admin, or an owner of the scope
GET /api/scopes/{scopeId}/permissions/{id}UC-32Same
GET /api/scopes/{scopeId}/permissionsUC-32Same
PUT /api/scopes/{scopeId}/permissions/{id}UC-33Same
DELETE /api/scopes/{scopeId}/permissions/{id}UC-34Same
DELETE /api/scopes/{scopeId}/permissions/{id}/hardUC-35System 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 operationEffect 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 DataOutput

Registration 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.

What an entry records

ColumnValue
PublicIdThe entry’s own GUID
ActorPersonIdThe acting person’s PublicId, or null for an anonymous write
ActorRoleThe acting person’s role value, or null for an anonymous write
ActionThe command’s CLR type name, e.g. "CreateApplicationCommand"
TargetIdBest-effort PublicId of the affected entity, or null if none could be resolved
CreatedAtUTC 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:

  1. issue a second credential (POST …/credentials);
  2. deploy its secret to the application;
  3. 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).

ClaimValue
issHEIMDALL_AUTH_TOKEN_ISSUER, when one is configured
audHEIMDALL_APPLICATION_TOKEN_AUDIENCE — never the person audience
exp, iat, nbfExpiry, issue time and not-before
tokenKindapplication
idThe application’s identifier
scopeIdThe identifier of the application’s scope
credentialIdThe credential the token was issued from
nameThe 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

EndpointStatusWhen
POST …/credentials201Credential issued; the secret is in the body
400expiresAt is not in the future, is not a date-time, or the body is not JSON (AF-53c)
401 / 403Anonymous; a User, or a Scope Admin who does not own the application or no longer owns its scope (AF-53b)
404No live application with that id in the addressed, live scope (AF-53a)
409Two live credentials already (AF-53d)
GET …/credentials200The credentials, newest first; an empty list when there are none (AF-54c)
401 / 403 / 404As above (AF-54a, AF-54b)
DELETE …/credentials/{credentialId}200Credential revoked
401 / 403 / 404As above, and 404 when the credential is not on this application (AF-55a to AF-55c)
409Already revoked (AF-55d)
POST /api/applications/token200Token issued
400A field missing, the secret over 100 characters, or a body that is not readable (AF-56a)
401Any other reason, always the same message (AF-56b)
429Rate limit; the Retry-After header gives the seconds to wait
GET /api/applications/self200Token valid and still live
401Any reason, always the same message (AF-57a)