Changelog
All notable changes to the Heimdall API are recorded in this file.
The format is based on Keep a Changelog , and this project adheres to Semantic Versioning .
Unreleased
1.6.0 - 2026-10-16
Security
- Second-factor failures at sign-in are now counted per account, across every challenge (FR-AU-12). App codes, email
codes and recovery codes all count, a correct password no longer resets the count, and only a successful second
factor does. The tenth failure locks second-factor verification for 15 minutes, twice as long at each repeat, up to
24 hours; the repeat count resets after a successful second factor. While locked,
POST /api/auth/2fa/verifyrefuses every code, the right one included, with its usual401and “The code or recovery code is missing, incorrect, or already used.”, so a client sees nothing new. - Re-authentication under a bearer token has a per-account budget of its own (FR-AU-13):
POST /api/auth/2fa/enable,2fa/confirm,2fa/disable,2fa/recovery-codes/regenerate,erasure-request(a person’s password) and an own email change throughPUT /api/persons/{id}. Five failures within 15 minutes pause these checks for the account for 15 minutes. The budget never touches the login lockout or the second-factor budget, so the owner can still sign in. A wrong or missing password, a wrong, missing or used code, and a paused budget all answer401with one message, “The password or code is incorrect.”. Clients: this replaces “The current password is incorrect.” and “The code or recovery code is missing, incorrect, or already used.” on those endpoints, and on2fa/confirmit replaces the400answers “The authenticator app code is missing or incorrect.” and “The email code is missing, incorrect, expired, or already used.” with the same401. A client that treats a401as the end of its session must recognise the new message as a refused credential instead. A Google User’s erasure request still answers “The credential presented was not accepted.”, but a person who sends no credential at all now gets the new message and is charged like a wrong password. A new migration,AddFactorBudgets, adds six columns toperson, all starting empty; only targeted writes change them, and anonymisation clears them. The data export (POST /api/auth/data-export) now includes all six insecurity, besidefailedLoginAttemptsandlockedOutUntil. Deploy order: deploy the heimdall-ui release that recognises “The password or code is incorrect.” and the401from2fa/confirmbefore this API, or together with it. Against an older UI, a mistyped password or code on any of these endpoints signs the user out. That UI release only adds the message, so it is safe to deploy first. Third-party clients need the same change before upgrading. Operators: no setting changes; the operations guide has SQL to release a second-factor lock or a re-authentication pause for a verified person. - A person’s own requests now share one account-email budget (FR-PE-16): asking for the verification link again (
POST /api/auth/resend-verification), starting two-factor setup with theEmailmethod (POST /api/auth/2fa/enable) and an own email change (PUT /api/persons/{id}, whose notice goes to the previous address). At most one such email perHEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS(five minutes by default) and five in each day-long window starting at the first send, reserved by one conditional write before anything is sent, so simultaneous requests cannot exceed it. Both refusals carryRetry-After, the seconds until a send would be accepted. A refusal is429and changes and sends nothing: “An email of this kind was sent to you recently. Try again in a few minutes.” inside the cooldown, “You have reached the limit for account emails. Try again later.” once the window’s five are spent. Two-factor setup checks it before the password and charges it only after, andAppalone is never charged; an email change charges it after the password and second factor, and a refusal spends no recovery or email code (an accepted authenticator code’s time step is recorded, as for any accepted code). Administrator-initiated mail (UC-51, UC-52) is not charged, so a person whose account was just created by an administrator, or whose budget is spent, can still be helped.resend-verificationis now also under the credential endpoints’ rate limit, whose429is “Too many requests.” withRetry-After. A new migration,AddAccountEmailBudget, adds three columns toperson, starting empty; they are in the data export and cleared by anonymisation. Clients: treat both new429s as “wait and try again”, not as a session end or a refused credential; the wait can be hours, andRetry-Aftergives it. An own email change counts toward the budget too, so asking for the new address’s verification link straight after the change waits out the cooldown. - Anonymous password recovery (
POST /api/auth/password-recovery) sends at most one reset link per person perHEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS, held to the reset link’s lifetime, counting links sent by an administrator too (FR-PR-07). The answer is unchanged. - A person can no longer change their own email while their email sign-in method is enabled, active or pending
(FR-PE-18):
PUT /api/persons/{id}answers409“Turn off the email sign-in method before changing your email address.” once the password and second factor are accepted. The re-authentication budget is cleared, as after any accepted check, and an accepted authenticator code’s time step is recorded; no account-email budget is charged, no recovery or email code is spent, and the email does not change. Clients: offer to turn the method off first; it can be set up again at the new address. An administrator’s change is unaffected. - Creating a person with a password (
POST /api/persons,POST /api/scopes/{id}/persons,POST /api/scopes/{id}/owners) is limited to 30 requests a minute per acting administrator and 30 a minute per address; a rejection is429“Too many requests.” withRetry-After. Only administrators’ access tokens count against the address, so requests without a token, or with a User’s, a two-factor challenge or a subject-rights token, from an address administrators share cannot use it up. - Self-registration bounds the mail one address can be sent (FR-SR-13, FR-SR-14): at most three live pending
registrations per address in a scope, and at most three “someone tried to register your address” notices per
address a day, across every scope. A request beyond either stores and sends nothing, and is answered
202exactly as before. The notice count is kept under a SHA-256 digest of the address in a new table,attempt_notice_budget(migrationAddAttemptNoticeBudget), and purged with the single-use tokens once its day has ended. - The credential endpoints’ rate limiter counts an IPv6 caller by its
/56rather than its/64, so a subscriber handed a/56is one caller instead of 256. Trusted callers’ named IPv6 addresses are counted the same way. Operators: the operations guide now shows how to add a rate limit for the credential routes at Traefik, alongside the API’s own limits. - Two-factor recovery codes now carry 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 returned by
POST /api/auth/2fa/confirmandPOST /api/auth/2fa/recovery-codes/regenerateasXXXX-XXXX-XXXX-XXXX. Each is stored as the SHA-256 of its sixteen characters. Wherever a recovery code is accepted (2fa/verify,2fa/disable,2fa/recovery-codes/regenerate, thetwoFactorCodeof an own email change) it is matched ignoring hyphens, whitespace and case, and an O, I or L in a sixteen-character code reads as 0, 1 and 1. Codes of the earlierXXXX-XXXXshape keep working, in any case and with or without the hyphen, until used or regenerated. Users: everyone who enabled two-factor authentication before this release should regenerate their recovery codes to get the stronger ones; the old set stops working once you do. Clients: accept and show codes of 19 characters as well as 9, and do not restrict their alphabet beyond letters, digits, hyphens and spaces. No migration: the column already holds a digest. - A first Google sign-in (
POST /api/auth/google) now requires the ID token’semail_verifiedto betrue(FR-GO-07). A token where it isfalseor absent creates no Google User and is answered403with the new message “Google sign-up requires a verified email address.”, before the address is compared with the scope’s. A Google User that already exists signs in as before, whatever the claim says. POST /api/auth/googleaccepts an optionalnonce(FR-GO-20): the value the client sent Google when it asked for the ID token. When it is present and not empty, the token’snonceclaim must equal it exactly, or the request is answered401“Google authentication failed.”, as for any token that fails verification. Without it nothing is compared. 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. Clients: generate a fresh random nonce for each Google sign-in, pass it to Google, and send the same value here.- A password-reset or verification link is checked against its account again when it is followed (FR-PR-08,
FR-EV-08): if the person has since been restricted, deleted or anonymised, or can no longer sign in through their
scope,
POST /api/auth/password-resetandPOST /api/auth/verify-emailanswer400“Invalid token.”, exactly as for an unknown token, and nothing is written. The new password and the verified flag are written only while the person still holds the address they were read with; the new password and a fresh security stamp are written by one statement, and once a person row exists its password hash and salt are written only by such targeted writes, so no whole-row save of an older copy can put an old password back. An email change, by the person or an administrator, now retires every live reset link as well as every verification link the person holds, before the new address is saved and again straight after (FR-PE-17); following one afterwards answers400“Token already used.”. Clients: no new messages; “Invalid token.” and “Token already used.” keep their meaning of “this link no longer works, ask for a new one”. - A Scope Admin reading another person now sees only the scope ownerships the two share (FR-PE-19). In
GET /api/persons/{id},GET /api/scopes/{id}/owners,GET /api/scopes/{id}/personsand the response ofPUT /api/persons/{id},ownedScopeIdslists, for a Scope Admin reading somebody else, only the scopes the Scope Admin owns too; a scope’s owners are each listed with that scope among them. A System Admin, and a person reading themselves, still see every scope.GET /api/persons/scope-adminsis unchanged: identifier, name and email. Clients: no status or message changes; do not read another person’sownedScopeIdsas the full list of what they own. - On an instance that serves a public host, EF Core’s sensitive data logging and detailed errors and the developer
exception page are off, whatever the environment, unless
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICSistrue(NFR-28). One decision at start-up sets all three, the exception page the host adds by itself in Development included, and the instance starts as usual with one warning naming the hosts. A host is public whenPUBLIC_HOSTorUI_HOST, the host ofHEIMDALL_EMAIL_VERIFICATION_URLorHEIMDALL_PASSWORD_RESET_URL, or aHEIMDALL_CORS_ALLOWED_ORIGINSentry, with or without a scheme, names anything butlocalhostor a loopback address;docker-compose.ymlnow passesPUBLIC_HOST,UI_HOSTand the new variable into the container. Operators: nothing to change; setHEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=truein an environment’s env file only where it holds made-up people and its diagnostics are wanted. Production and a local run are unaffected. Clients: a public-host instance does not answer with the developer exception page unless the override istrue; a failed request gets the usual500“Internal server error, please try again later”. - The supported database setup runs the migrations as an owner role and the API as a role that owns nothing
(NFR-29). The container entrypoint migrates with
HEIMDALL_MIGRATIONS_CONNECTIONSTRINGwhen it is set, and withHEIMDALL_DATA_CONNECTIONSTRINGas before when it is not. It hands the connection string to the migration bundle through the bundle’s environment rather than its command line, and removes the migrations string before the API starts, which never reads it. Operators: optional, and applied by you: the operations guide’s “Database roles” has the grants, the default privileges for tables later migrations add, the steps for a new database and for switching an existing one over, and the checks. Deploy this release before switching. The env templates list the new variable;docker-compose.ymlpasses it through. - Every token the API reads is now checked for its issuer and audience as well as its signature and lifetime
(NFR-30). A bearer token must name
HEIMDALL_AUTH_TOKEN_ISSUERas its issuer and exactly one of the person,.2fa-challengeand.subject-rightsaudiences; the challenge token atPOST /api/auth/2fa/verifyand2fa/challenge/resend, the challenge audience; an application token atGET /api/applications/self, the application audience. A token Heimdall did not issue for that reader is refused however it is signed, with the answer the endpoint already gives a token it cannot use: as a bearer token,401“Invalid token”, as for a bad signature; at2fa/verify,401“The two-factor challenge is invalid or has expired. Log in again.”; at2fa/challenge/resend, the usual200“If the challenge is still valid, a new code has been sent.”, with nothing sent; atGET /api/applications/self,401“The application token was not accepted.”. Clients: tokens Heimdall issues are unaffected, and no new message is introduced. - Tokens are accepted under HS256 only, the algorithm they are signed with, now that the API is on
ArturRios.Jwt1.3.0, declared directly in the Web API project. A token under HS384 or HS512 is refused as a bearer token with401“Invalid token”, and at the two challenge endpoints andGET /api/applications/selfas above. - A browser front end on an origin in
HEIMDALL_CORS_ALLOWED_ORIGINScan now readRetry-After: the API lists it inAccess-Control-Expose-Headers, so the wait a429gives reaches a cross-origin client too. - Start-up checks the signing secrets (NFR-31):
HEIMDALL_AUTH_TOKEN_SECRET, andHEIMDALL_AUTH_TOKEN_SECRET_PREVIOUSwhen it is set, must each be at least 32 bytes and ASCII only, or the API refuses to start with a message that names the variable and never the value. A previous secret that is unset, blank or""means no previous key. Operators: before deploying, check that every environment’s secrets meet the rule;openssl rand -base64 48gives 64 bytes.
1.5.0 - 2026-10-15
Added
- UC-53 to UC-57 (#178): a registered application can authenticate to Heimdall as itself. A System Admin, or the
Scope Admin who owns the application and its scope, issues it up to two live credentials
(
POST /api/scopes/{scopeId}/applications/{id}/credentials), lists them (GETthe same path) and revokes one (DELETE …/credentials/{credentialId}). The secret (hds_…) is returned once and only its SHA-256 is stored; a third live credential is refused with409. The application exchanges a secret for a ten-minute token (POST /api/applications/token, anonymous and rate-limited, every refusal the same401), and a consumer re-checks that token live withGET /api/applications/self, which refuses it as soon as its credential is revoked or expired or its application or scope is deleted. Application tokens carry their own audience and no role, and Heimdall’s own endpoints refuse them. A new migration,AddApplicationCredential, adds theapplication_credentialtable; the retention purge removes spent credentials. Operators: new variablesHEIMDALL_APPLICATION_TOKEN_AUDIENCE(required) andHEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS(default600); see the consumer guide, “Application authentication”. - UC-51 and UC-52: an administrator can have a verification link (
POST /api/persons/{id}/verification-email) or a password-reset link (POST /api/persons/{id}/password-reset) mailed to a person’s own address — a System Admin for anyone, a Scope Admin for Users of scopes they own. No token is ever returned. Deleted or restricted persons are refused (409), as are a verified address (400) and, for a reset, an account that could not sign in (409, UC-12’s rule, now shared). At most one email of each kind per person per cooldown: a repeat answers429. The cooldown is best-effort. Operators: the new optionalHEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDSsets it (default300,0turns it off; unusable values fall back to300);docker-compose.ymlpasses it through. - UC-49 and UC-50: a scope’s owners and System Admins can list the self-registrations awaiting confirmation
(
GET /api/scopes/{id}/pending-registrations— name, address, expiry, guesses left, never the password hash or token) and discard one (DELETE /api/scopes/{id}/pending-registrations/{registrationId}), after which its link answers “Invalid token”. Discards are audited. A new migration,AddPendingRegistrationPublicId, gives each pending registration a random public id, backfilling existing rows. GET /api/persons/system-adminslists the deployment’s System Admins (UC-07 read e, FR-PE-13), so one can see who else holds full access. System Admin only; a Scope Admin or User is answered403. Paginated, with optional case-insensitivenameandemailfilters, ordered by name, and projected toid,nameandemail, asGET /api/persons/scope-adminsis. Logically deleted and restricted (NFR-24) administrators are not listed.- UC-48:
GET /api/audit-loglists the audit trail for System Admins, newest first, filterable by when an entry was recorded (from/to, UTC, inclusive),actorId,action(the command’s type name, matched exactly),targetIdandsucceeded. An actor the retention pass has cleared reads asnulland is never reconstructed. A new migration,AddAuditLogTargetIndex, indexesaudit_log.target_idso filtering by target does not scan the table.
Changed
- 2FA challenge tokens and subject-rights-only tokens now carry their own audience (
HEIMDALL_AUTH_TOKEN_AUDIENCEplus.2fa-challengeand.subject-rights, respectively); consumers that pin the person audience reject them. Full authentication tokens keepHEIMDALL_AUTH_TOKEN_AUDIENCE, and Heimdall accepts a challenge or subject-rights-only token only under its own audience. Clients that pass the challenge token back toPOST /api/auth/2fa/verifyunread need no change. Operators: no new variable; start-up logs the three audiences, and now refuses aHEIMDALL_APPLICATION_TOKEN_AUDIENCEequal to either derived audience. Challenge and subject-rights-only tokens issued before the upgrade are refused once, so those users sign in again; full tokens are unaffected. During a rolling deploy, instances not yet upgraded still issue the old tokens, which upgraded instances refuse, and the separation holds only once every instance is upgraded. Consumers that don’t validate the audience should start doing so, pinned toHEIMDALL_AUTH_TOKEN_AUDIENCEexactly; see the consumer guide, “Login: audiences”. - A present but unusable
HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS,HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDSorHEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDSnow logs a warning at start-up naming the variable, the rejected value and the default applied. The auth token lifetime also now falls back to its default (3600) when zero, negative or not finite, where it was used as written, and the lifetime settings now parse with the invariant culture rather than the host’s. - A request turned away by the
AuthAnonymousrate limit (429) now carries aRetry-Afterheader, in whole seconds, and the usual error envelope with the message “Too many requests.”, instead of an empty body. - Contributors: the
dotnet-eftool in.config/dotnet-tools.jsonis now pinned to the version ofMicrosoft.EntityFrameworkCore, andscripts/vulnerabilities.pyfails when the two drift apart. - Upgrading:
HEIMDALL_APPLICATION_TOKEN_AUDIENCEis now required and must differ fromHEIMDALL_AUTH_TOKEN_AUDIENCE; the API refuses to start otherwise.docker-compose.ymldefaults it toheimdall-applicationsand thedocker/*.env.exampletemplates preset that value, so a Compose deployment keeps starting. The Web API’sEnvironments/.envholds only a placeholder; any other deployment must set it. Application tokens are validated with zero clock skew, so keep instance clocks synchronised (NTP). - The audit retention pass (NFR-21) now also clears an entry’s
TargetIdwhen it names a person or Google User, on the same two grounds as the actor: the entry is past the attribution period, or that identity has been anonymised (#184). A self-service entry, or an administrator’s action on a person, therefore stops naming that person once its attribution is cleared. Targets naming a scope, an application, a credential or a permission are kept. A new migration,AllowClearingAuditTarget, replaces the append-onlyUPDATEtrigger withaudit_log_attribution_clear_only(), which permits exactly this one more change — a person target set toNULLonce due, never to another value — and also makes the rowidimmutable.GET /api/audit-logreads a cleared target astargetId: null, and filtering bytargetIdno longer finds it. Operators: no setting changes. Existing entries already past the period are cleaned over the next scheduled runs, a batch (HEIMDALL_RETENTION_PURGE_BATCH_SIZE) at a time, and the run’s log line now reports both counts. - The audit retention pass (NFR-21) now also clears the target of a person or Google User who was hard-deleted
(UC-10, UC-29, and the members UC-05 removes), including the hard delete’s own entry, on its next run and at any
age (#195). Before, a removed person’s id stayed in the trail for good, because with the row gone the trigger could
not tell it had named a person. Each hard delete now first writes a tombstone — the id, whether it was a person or
a Google User, and when — to a new
erased_subjecttable (migrationAddErasedSubject); a retried delete writes it again without error. A trigger on that table accepts a row only for a person or Google User that still exists, so a tombstone cannot be planted to make any other target clearable. A target whose id is tombstoned and no longer resolves to a row is due, and a new migration,AllowClearingErasedSubjectTarget, lets theaudit_log_attribution_clear_only()trigger accept exactly that clearing; nothing else it permits changes. The pass removes a tombstone once it is a day old and no entry names it, and its output and log line now report how many it removed. Operators: no setting changes. Targets orphaned before this change are not recoverable: the ids of persons and Google Users hard-deleted earlier were never tombstoned, so nothing records that they named a person, and they stay in the trail as recorded. Clearing them would need a list of those ids from outside the database; this release does not attempt it. PUT /api/persons/{id}andPOST /api/auth/2fa/enableare now under theAuthAnonymousrate limit (10 requests a minute per client address,429withRetry-Afterbeyond it), like the other endpoints that check a password. The person update is limited whether or not it checks one, administrators’ updates included. Operators: no setting changes; a deployment behind a proxy needsHEIMDALL_TRUSTED_PROXIESset, as for login, or every caller shares one budget.
Fixed
- A sign-in attempt cut short at the lockout threshold now locks the account for the usual period instead of
leaving it unable to sign in. An account already left at the limit without a lock is handled normally again by
its next sign-in attempt, which locks it for the usual period, or clears it if the password is right. A new data
migration,
ReleaseStuckLoginLockouts, also resets such accounts. - The Command tests no longer fail intermittently on password-hash gate timeouts under load (#196): the test assembly installs a gate with the production permit count and a two-minute wait, and the saturation exception’s message now names the gate and how long it waited. Production behaviour and responses are unchanged.
- A request that cannot be bound now answers
400with “The request is malformed.” on every endpoint (#186), instead of running the action with a missing or default argument: a body that is not JSON was a500, and a query filter of the wrong type (?succeeded=maybe,?actorId=not-a-guid) was dropped and answered200unfiltered. A globalMalformedRequestFilterreplaces the two per-action guards added in #178; it runs after authentication and role checks, so an unauthenticated caller still receives401. A non-nullable query parameter sent empty or unparseable (?pageNumber=,?pageNumber=abc,?includeDeleted=1) is now a400rather than its default; a missing required field keeps its validator message.
Security
- A
public_idcan no longer be rewritten, or borrowed from another entity (#199). The audit trail’s retention rules (#184, #195) read a person’s or Google User’s id as proof that an audit target names a person, so a row given a scope’s, an application’s or a credential’s id, then anonymised or tombstoned, made targets that never named a person clearable. Two new migrations add triggers toperson,google_user,scope,application,application_credential,scope_permission,roleandpending_registration.ProtectIdentityPublicIdscreatespublic_id_is_immutable(), which refuses anyUPDATEthat changespublic_id, andpublic_id_is_unused(), which refuses anINSERTwhosepublic_ida row of any other of those tables holds, soft-deleted or not, or a hard delete tombstoned, and, for a person or Google User, one that an audit entry names, so a hard-deleted entity’s id cannot be taken over while the trail still names it; it binds both to the identity tables.ProtectEntityPublicIdsbinds both to the other six, sopublic_idis immutable on every table the check trusts, which closes the rename-then-reuse path, and the check runs whichever table is written second. Every other column stays writable, and the API only ever creates fresh ids and never changes one, so no request behaves differently. Operators: no setting changes. The triggers guard future writes only; the migrations do not look for a duplicate already stored, which could exist only if somebody wrote one by hand in SQL. Operations, “Shared public ids”, gives two queries to run before migrating; each should return no rows. Once migrated, hand-written SQL that rewrites apublic_id, such as a support fix-up or a restore that remaps ids, fails withrestrict_violation. - Person tokens are now revoked when a credential changes. Every person token (full, 2FA challenge and
subject-rights-only) carries the person’s security stamp in a new
sstclaim, and a token whose stamp is missing or out of date is refused on every endpoint with401and “This token has been revoked. Sign in again.” The stamp is replaced by a password reset, an email change (by the person or by an administrator), confirming or disabling two-factor authentication, and regenerating recovery codes; the token that made the change is revoked with the rest.POST /api/auth/2fa/verifyrefuses a challenge issued before such a change as an invalid challenge. A new migration,AddPersonSecurityStamp, addsperson.security_stamp, filled with a fresh value per row; only a targeted write replaces it, so a concurrent whole-row save cannot restore an old one. Google User tokens are unchanged. Operators: tokens issued before the upgrade carry no stamp and are refused, so everyone signs in once more after deploying; during a rolling deploy, tokens issued by instances not yet upgraded are refused by upgraded ones until every instance is upgraded. The migration rewrites thepersontable (its default is evaluated per row) under an exclusive lock, which blocks sign-ins and every other read or write of the table until it finishes; on a large table, plan for that lock time. Consumers that validate person tokens offline do not see the stamp, so a revoked token stays valid at them until it expires. Revocation and the re-authentication below hold only once every instance runs this version: until then, an instance not yet upgraded still accepts revoked tokens and own email changes without a password. - Changing your own email through
PUT /api/persons/{id}now requirescurrentPassword, andtwoFactorCode(an authenticator-app or email code, or a recovery code, which is spent) when your two-factor authentication is active. No endpoint sends an email code to a signed-in caller, so a person whose only method is Email uses a recovery code. A missing or wrong password answers401with “The current password is incorrect.”, and a missing or wrong second factor401with “The code or recovery code is missing, incorrect, or already used.”, before the new address is checked, and nothing changes. An administrator changing someone else’s email, and anyone changing only their name, send neither. The email counts as changed unless it matches exactly after trimming, so a change of case alone also asks for the password, revokes tokens, sends the notice and resetsemailVerified. Starting two-factor setup (POST /api/auth/2fa/enable) now requirescurrentPasswordtoo, refused the same way. Any email change also sends a notice to the previous address, which names neither the new address nor a link; a failed delivery is logged and does not fail the update. Addresses are now stored trimmed, on creation and on update; an update that differs from the stored address only by surrounding whitespace is no change. Clients: sendcurrentPassword(andtwoFactorCodewhen 2FA is active) for an own email change, andcurrentPasswordto enable 2FA; after an own email change, a password reset, confirming or disabling 2FA, or regenerating recovery codes, sign in again. Operators: release the heimdall-ui version that sendscurrentPasswordtogether with this API, or before it; an older UI gets401on 2FA enable and on an own email change.
1.4.0 - 2026-10-13
Added
- UC-47: anonymous self-registration (
POST /api/auth/register) into scopes that open it withPUT /api/scopes/{id}/self-registration. Answers new and taken addresses identically; a taken address’s holder is emailed about the attempt. The account is created when the emailed link is followed (POST /api/auth/verify-email) with the password chosen at sign-up, with its address verified; until then nothing can sign in. Confirmation needs that password: without it the answer is422, with a wrong one401, neither consumes the registration, and the 10th wrong password discards it. A link followed again once the account exists answers “Invalid token”. - Per-scope email links (FR-SR-10).
PUT /api/scopes/{id}/email-linkssets the base links a scope’s verification (and sign-up confirmation) and password-reset emails point at, so a client system such as Fortuna can send its users to its own/verify-emailand/password-resetscreens. They replaceHEIMDALL_EMAIL_VERIFICATION_URL/HEIMDALL_PASSWORD_RESET_URLfor that scope’sUsers only; administrators and a scope with none set keep the global links. Each must be an absolutehttporhttpsURL of at most 2048 characters.emailVerificationUrlandpasswordResetUrlare returned on every scope response. A new migration,AddScopeEmailLinks, adds the two nullable columns. - Trusted caller keys for the anonymous-auth rate limit. A client system such as Fortuna’s API reaches Heimdall
from one address for every end user, so they shared one bucket of 10 requests a minute. A request carrying a
key from the new
HEIMDALL_TRUSTED_CALLER_KEYSinX-Heimdall-Caller-Keyis now counted under the address inX-Heimdall-Client-Address(an IPv6 address by its /64), in a bucket of its own that a direct connection from that address does not share. A wrong or missing key, or an unusable address, falls back to the connection address, as before. Operators: the variable is optional; each key must be at least 32 characters or start-up is refused, and two may be listed to rotate.docker-compose.ymlpasses it through.
1.3.0 - 2026-10-11
Changed
A subject whose processing is restricted (NFR-24) can sign in again, for their own rights only (#145). Until now
POST /api/auth/login,POST /api/auth/2fa/verifyandPOST /api/auth/googlerefused a restricted identity with the uniform401, so once the token held at the time of the restriction expired the subject could no longer lift it (UC-45), export (UC-41) or request erasure (UC-42) without a System Admin. They now answer200with a subject-rights-only token and a newprocessingRestricted: truefield (UC-11 AF-11i, UC-25 AF-25e). That token claims no scope, and every endpoint other thandata-export,erasure-request,processing-restrictionandprocessing-restriction/liftanswers it403— still so after the restriction is lifted, when the subject signs in again for a full token. It never lifts anybody else’s restriction, whatever role it claims. The check comes after the password, lockout and second-factor checks, so every refusal still answers alike, and a suspended (not yet anonymised) restricted subject is admitted too. A restricted person with email two-factor authentication is now sent the code they sign in with, and2fa/resendsends it again on request.ArturRios.Messaging1.3.0 → 1.4.0 (#147). Email no longer goes out as “Mailgun Sandbox”: the newMAILGUN_FROMvariable sets the sender (Heimdall <no-reply@example.com>, or a bare address), and without it mail is sent from a barepostmaster@<MAILGUN_DOMAIN>. Operators: setMAILGUN_FROMin each environment’s env file — the templates indocker/anddocker-compose.ymlnow carry it. A value that is not exactly one address makes every send fail, logged with the variable’s name. The library also stops writing recipient addresses to the log; its lines carry a pseudonymousRecipientRefinstead.The tests run on xUnit v3:
xunit2.9.3 →xunit.v3.mtp-off4.0.2 andArturRios.Util.Test4.0.0 → 5.0.0, the release built on xUnit v3. Themtp-offflavour runs through VSTest, sodotnet test, its filters and the.trxlogger work as before. Test-only; the HTTP API and the OpenAPI document are unchanged.
Fixed
- An erasure requested while the subject’s processing was restricted could miss its legal deadline: NFR-24 kept the
restricted record out of the anonymisation pass, so the request waited, unanonymised, until somebody lifted the
restriction.
POST /api/auth/erasure-request(UC-42) now lifts the subject’s own restriction in the same write — only the subject can impose one (UC-44), and the request already demands their credential — whether the request suspends the identity or is recorded but blocked (AF-42d), for persons and Google Users alike. It is lifted as a subject lifts their own (UC-45), so no Art. 18(3) notification is sent. The response carries a new boolean,restrictionLifted(additive), and the audit trail records aLiftProcessingRestrictionCommandentry on the subject beside the request’s own. A restriction imposed after the request still holds the erasure until it is lifted. UC-42, UC-44, NFR-24 and the API reference say so; the OpenAPI document is regenerated (#146).
1.2.0 - 2026-10-09
Changed
- A System Admin may own a scope (FR-SC-08).
POST /api/scopesandPOST /api/scopes/{scopeId}/owners/{personId}accept a liveSystemAdminas an owner as well as aScopeAdmin, so a System Admin can create a scope with themselves as its owner when no Scope Admin exists yet — before, the request was refused with “One or more owners do not reference an existing, non-deleted ScopeAdmin.” A System Admin who owns a scope may also own its applications (FR-AP-03).GET /api/persons/scope-adminslists System Admins too when a System Admin calls it; a Scope Admin still sees Scope Admins only. The owner-validation messages name both roles. NFR-12 counts a System Admin owner like any other: deleting, hard-deleting, or erasing the last owner of a scope is refused whatever their role. - Every dependency is on its latest stable release again:
Testcontainers.PostgreSql4.15.0 → 4.16.0 andxunit.runner.visualstudio4.0.0 → 4.0.1 (both test-only), and the documentation site’s Docsy theme 0.16.0 → 0.18.0, which now needs Dart Sass (1.105.1 in the docs workflow) to build its stylesheets and takes the Mermaid settings indocs/hugo.tomlunderparams.docsy.plugins.mermaid. The theme submodule now points at Docsy’s new home,github.com/docsy/docsy. The HTTP API and the OpenAPI document are unchanged. - The functional tests run against PostgreSQL 18 (
postgres:18-alpine, waspostgres:16-alpine), the major version the deployed environments run.
1.1.1 - 2026-10-09
Fixed
- The health check moved to
GET /api/healthcheckandGET /api/healthcheck/detailed. At the root it was unreachable from the web UI’s host, which routes only/api/to the API, so heimdall-ui’s health screen showed errors in every deployed environment. The root/healthcheckaddresses still answer, for the container health check and yggdrasil’s status page, but are no longer in the OpenAPI document.
1.1.0 - 2026-10-09
Added
HEIMDALL_TRUSTED_PROXIESlists the reverse proxies (addresses and CIDR networks) whoseX-Forwarded-ForandX-Forwarded-Protoheaders are believed, one hop deep, so the rate limiter and the request log see the real caller. Unset, nothing is trusted and start-up warns.- Every request’s client IP address is logged, and kept with the application logs for 12 months. The Privacy Notice (now 1.2), the Data Retention Schedule and the Data Protection Document declare it.
- A Scope Admin can list scopes:
GET /api/scopesreturns the scopes they own, its total counting only those, with ownership read from the database. A System Admin still lists every scope; a User is still refused (403).
Changed
- The credential-checking authenticated endpoints —
POST /api/auth/2fa/confirm,2fa/disable,2fa/recovery-codes/regenerateanderasure-request— share the 10-a-minute rate limit the anonymous ones have, and an IPv6 caller is now counted by its/64rather than per address. - A migration adds
two_factor_auth.challenge_id. A 2FA challenge issued before the upgrade is refused after it, so a login in flight at deploy time has to sign in again. - A migration adds
two_factor_auth.challenge_attempts(integer, default0; additive, no table rewrite). POST /api/auth/password-recoveryanswers before it looks the address up: the lookup, the token and the email are done by a background worker, so the reset email arrives just after the response rather than before it. A restart drops recovery requests still queued (the person asks again), and a full queue (1,024) drops and logs.- A login attempt is counted in
failed_login_attemptswhile it is in flight, and cleared if the password was right, so every login now writes that counter. - Every dependency is on its latest stable release, including
ArturRios.Util.WebApi5.1.0,ArturRios.Data.Relational.Core5.0.0,Mediator2.0.0,Swashbuckle.AspNetCore10.3.0 and OpenTelemetry 1.19. The HTTP API and the OpenAPI document are unchanged. - Four deployment environments instead of three:
local(Docker Desktop on Windows, by hand), anddevelopment,homologationandproduction, which share one VPS and are deployed by yggdrasil —developto development,release/x.y.zto homologation, the release pull request to production. Development and homologation run on demand. The Development-in-WSL setup is gone. A newdocker/homologation.env.exampleruns homologation asStaging; the development and production templates now describe the VPS (PUBLIC_HOST,UI_HOST, Traefik’s Docker range inHEIMDALL_TRUSTED_PROXIES, one database and login per environment) and the local one points CORS and the e-mail links at the local heimdall-ui onhttp://localhost:8081. The e-mail templates’ reset link is/password-reset, the route heimdall-ui serves. The docs page “Deploying with Docker” is now “Environments and deployment”; the old address redirects to it.
Removed
scripts/deploy_wsl.py, which deployed into a WSL distro for the retired Development-in-WSL environment.
Fixed
- Login and password recovery resolve the live person when a logically deleted one still holds the same address, instead of refusing the live one until the deleted one is anonymised.
- A record under a restriction of processing is no longer anonymised, nor moved toward erasure when a blocked request clears (NFR-24).
- Re-applying erasures after a restore also removes the person’s two-factor configuration, recovery and email codes, and reset and verification tokens, as the scheduled anonymisation does.
- An email code that authorizes a recovery-code regeneration is spent, as one that completes a login is.
- Restricted Scope Admins are withheld from the scope-owner and scope-admin listings, as restricted Users already were.
- A page number large enough to overflow the page offset answers
400instead of500; the message now states the accepted range. - The retention settings accept the lower bound of their documented ranges (1, 30 and 1 days, 1 minute).
- A restricted subject can reach the endpoints through which they exercise their own rights — data export, erasure
request, restriction (answered
409, already restricted) and lifting their own restriction — instead of401everywhere (NFR-24, UC-41, UC-44, UC-45). A suspended, not yet anonymised subject can export and restrict, as UC-41 and UC-44 say, and a repeated erasure request answers409rather than401. Every other endpoint still refuses them, and a restricted System Admin cannot lift anybody else’s restriction. - Validating a 2FA challenge honours the previous signing keys, as bearer tokens do, so a key rotation no longer voids challenges in flight.
- The OpenAPI document describes
GET /api/auth/2faandPOST /api/auth/data-exportwith their own summaries; the two-factor status summary had been attached to the export. - The OpenAPI document’s
info.licensenames the project’s actual licence, “ArturRios.Heimdall — Proprietary License”, instead of MIT, which it never was. Its description no longer says a 2FA challenge token works only atPOST /api/auth/2fa/verify: it is never a bearer credential, andPOST /api/auth/2fa/challenge/resendreads it from the request body too.
Security
- A 2FA challenge token can be redeemed once (FR-2F-10). It stayed valid for its whole ten minutes, so whoever held a spent one could trade any further factor for another full token without the password. A newer login also replaces the previous challenge.
- A restricted identity can no longer authenticate through Google Sign-In or by completing a 2FA challenge issued before the restriction (NFR-24); both used to issue a full token.
- Changing a person’s email retires their outstanding verification tokens, which could otherwise mark the new, unproven address verified.
- A Scope Admin removed as an owner of a scope can no longer read, update or delete the applications they still own in it.
- Two-factor email codes are hashed and checked under the process-wide Argon2id concurrency bound
(
PasswordHashGate), which they bypassed. - The Mailgun library’s own log lines, which wrote the recipient’s address in the clear, are suppressed (NFR-22); Heimdall already logs every delivery outcome under a non-reversible reference.
- Password recovery takes the same time whether or not the address is registered (AF-12a). A registered address used to wait for the token insert and the Mailgun round trip before answering, which told an anonymous caller the address existed.
- Single-use items are spent once and budgets hold under concurrent requests (NFR-27): recovery codes, email codes,
password-reset and verification tokens, the 2FA challenge and the TOTP time step are each spent by one
conditional
UPDATE, and the login lockout, the email-code guess limit and the code-reissue limit are charged before the comparison. Parallel requests could redeem one recovery code or token several times, trade one challenge for several tokens, and lose lockout increments so ten simultaneous wrong passwords never locked the account. - A 2FA challenge allows five guesses with an authenticator-app code or a recovery code, then is retired, so further guessing needs the password again (FR-2F-17). Only the per-IP rate limit bounded them before.
1.0.0 - 2026-09-28
First release.
Added
- Scope-based multi-tenancy: scopes, their owners, and the System Admin, Scope Admin and User roles.
- Person and application (non-human identity) management, each with logical and hard deletion and defined cascade rules.
- Scope-specific permissions, optionally folded into the JWT issued within the scope.
- Password login (JWT), password recovery, email verification, and email delivery through Mailgun, with a logging fallback when no credentials are configured.
- Two-factor authentication by authenticator app and/or email, with single-use recovery codes.
- Google Sign-In per scope, and administration of the scope’s Google Users.
- LGPD and GDPR data subject rights: self-service export, erasure and restriction of processing, with the administrative counterparts.
- Data protection: a retention schedule, anonymisation of deleted identities, purging of expired single-use tokens, audit attributions cleared once due, bounded log retention with address redaction, the lawful basis and notice version recorded per identity, restore reconciliation against the erasure ledger, and security signal detection.
- Audit logging of write operations, rate limiting, liveness and detailed health checks, and Prometheus metrics on a private port.
- Deployment with Docker Compose against a host PostgreSQL, applying pending EF Core migrations at container start-up.