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
4.2.0 - 2026-10-29
Added
HEIMDALL_INSECURE_TEST_PASSWORD_HASHING=truederives every password and code with the cheapest Argon2id cost (one lane, one pass, 8 KB) instead of 600 MB and 16 lanes, for the test suite, which sets it intests/default.runsettings. At the production cost the suite’s test process grew past the CI agent’s memory limit and was killed, and Command.Tests took two and a half minutes; it now takes seconds. Unset, or any value buttrue, keeps the production cost. Startup refuses it in Production and Staging, since it makes every hash trivial to brute-force and no hash stored under it verifies once it is removed.- A scope now holds at most
HEIMDALL_REGISTRATION_MAX_PENDING_PER_SCOPE(default 1000) live pending self-registrations, whatever their addresses (FR-SR-15, AF-47m).POST /auth/registerhad only the per-IP limit against a caller with many source addresses, and each request stored a row and mailed a link, so the table grew without bound. At the cap the request is answered exactly as one that was stored, stores and mails nothing, and is logged at Warning with the markerREGISTRATION_CAP_REACHEDand the scope’s public id, never the address. The existing bound of three live registrations per address (FR-SR-13) is unchanged. The variable is in thedocker/*.env.exampletemplates, the README andoperations.md(#206). - A scheduled pass that fails now logs at Error with the marker
SCHEDULED_PASS_FAILEDand the pass name, where it was logged at Information and no alert on Warning or above could fire; a pass that throws carries the marker too. Each pass also publishesheimdall_scheduled_pass_rows_processed_totalandheimdall_scheduled_pass_last_success_timestamp(unix seconds, set only by a run that completed without failure), labelled bypass, so an operator can alert on “no successful run in N hours”. Both are described inoperations.md(#214). GET /api/healthcheck/ready, anonymous, answers200while the database responds and503when it does not, with only the bare status (HealthyorUnhealthy) in the body, so an orchestrator that runs more than one instance can tell one that has lost its database from one that is up. The answer is cached for 5 seconds, so the probe costs the database one read per window however often it is called, and its503costs one Warning per window rather than one per request./healthcheckstays the liveness probe and reads no database, and it stays the one the Compose container health check calls: each environment runs a single instance, so a readiness probe would take that instance out of Traefik’s routing during a database outage and the public host would answer Traefik 404s instead of the API’s JSON503.operations.mdexplains which to use for what and when to switch (#215).- Every
5xxa handler returns is now logged at Error with the route template, the status code and the trace id, and nothing of the query string, body or caller; a503, which is always a deliberate answer here, is logged at Warning. An unhandled exception was already logged, so it is not logged twice (#227).
Fixed
- Login, password recovery, registration and Google sign-in no longer read the whole
persontable to find an address. They compareLOWER(email)withoutis_deleted, which neither the index on the raw column nor the partial unique indexes could serve, so each lookup was a sequential scan before the password hash. A migration buildsix_person_lower_emailonLOWER(email)withCREATE INDEX CONCURRENTLY, so writes are not blocked, and drops the unusedix_person_email. On 150,000 rows a lookup went from a sequential scan of about 29 ms to an index scan of well under 1 ms (#207). - The token purge, the identity anonymisation and the audit pseudonymisation now drain a backlog: each repeats its
batch while the batch comes back full, up to
HEIMDALL_RETENTION_MAX_BATCHES_PER_TICKbatches (default 20) per table in one run, where one batch a run capped a table at 12,000 rows a day and a busy audit trail could fall behind for good. Each run’s log line now ends with the rows still due after it, so a backlog that is not draining is visible. A run that fails part-way still counts the rows its earlier batches processed, and the anonymisation’s erasure ledger still names every identity the run anonymised, from every batch, even when a write throws; if only counting what is still due fails afterwards, the run stays a success and its line says an unknown number is still due. The retry of blocked erasure requests takes its batches the same way, each after the last request of the one before, so requests still blocked can no longer keep newer ones from being retried. The variable is in thedocker/*.env.exampletemplates andoperations.md(#206). - The audit pseudonymisation pass no longer reads the whole attribution window on every run. It evaluated up to
four correlated subqueries for every attributed entry, so a run with nothing due still examined every entry of
the last eighteen months, on every instance. It now asks each ground separately, projecting ids: entries past
the period through two new partial indexes on
audit_log(created_at)(over the entries that still have an actor, and over those that still have a target), and entries naming an anonymised or hard-deleted identity from those identities. What it clears is unchanged. On a 305,000-entry trail with nothing due the selection went from 430 ms to about 80 ms, most of it the re-check of old entries whose target is not a person, which keep their target for good. TheAddAuditRetentionIndexesmigration builds the indexes withCREATE INDEX CONCURRENTLY, so audit writes are not blocked while it runs (#206). - One blocked erasure request that cannot be retried no longer stops every anonymisation. The retry now runs
best-effort: its failure is logged and reported with the run’s errors, and the identities that are due are still
anonymised. The retry reads blocked requests
PurgeBatchSizeat a time, oldest first, where it loaded them all (#210). - A login the saturated password-hash gate sheds with
503(and any other command that throws instead of answering) now leaves a failed audit entry, and the exception still propagates: the gate’s refusal recordsAuthenticationTemporarilyUnavailable, anything else a fixed reason, never the exception’s text. TheCREDENTIAL_VERIFICATION_SHEDDINGsignal counts that reason, so it can now fire; before, nothing wrote it (#201). - A first-time Google sign-up whose ID token carries no email claim, as when it was issued without the
emailscope, is now answered with AF-25a’s401 Unauthorized— “Google authentication failed.” — and creates nothing, where it used to fail with a 500 (#208). Use case 25’s AF-25a now names the case. PUT /api/persons/{id}refuses an email longer than 256 characters with the length message creation uses, where it used to reach the database and fail on the index’s row-size limit with a generic error (#223).- The master System Admin the API seeds on an empty database now has its legal basis recorded (NFR-23): contract
performance, the current privacy notice version and the time. A new migration records the same on System Admin
rows still at Unrecorded (
legal_basis = 0). The migration cannot tell the seeded master from another System Admin that predates NFR-23, so it records all of them; a row created since then is never at 0 (#213). docs/content/en/docs/operations.mddescribed the rolling log files as<yyyy/MM>/log-<date>.json, a folder per month. They are<HEIMDALL_LOG_DIRECTORY>/log-<yyyyMMdd>.json, one per day, flat in the directory, as the retention section of the same page already said (#216).- Confirming two-factor setup (
POST /api/auth/2fa/confirm) checks the email code before the authenticator code, so a wrong email code no longer spends a right app code’s 30-second step and the caller can retry with the same one (#225). Use case 37 now says so. - A change of an email address that differs only in case (
PUT /api/persons/{id}) keepsEmailVerified = true: it is the same mailbox, as sign-in, recovery and uniqueness already treat it. It is still a change for everything else — it asks for the password, revokes the person’s tokens and notifies the previous address (#225). Use case 8 now says so. - The personal data export (
POST /api/auth/data-export) now includes the audit entries where the caller is the target as well as those they made, such as an administrator’s update of their account (#225). Each entry gains aninvolvementofActororTarget; aTargetentry does not name who acted. - A single-use link or code is now spent only together with the write it unlocks. Resetting a password, verifying an address, confirming a pending registration and confirming two-factor setup each spent the token, code or registration first and wrote afterwards, so a database failure in between cost the caller their link and answered with an error; for a pending registration, a refusal after the claim (the scope closed, the address taken) lost the registration for good. Each now spends and writes in one transaction, and a failure leaves the link, code or registration usable. Retiring a person’s other links, or an address’s other pending registrations, still runs after the write, and a failure there is logged at Warning instead of turning a reset or verification that happened into an error. A reset or verification refused because the person changed meanwhile still spends the link (FR-PR-08). Use cases 13, 14 and 37 say so (#209).
- Hard-deleting a person (
DELETE /api/persons/{id}/hard) or a scope (DELETE /api/scopes/{id}/hard) is now all or nothing. Each step — the tombstones, the applications, tokens, Google Users and Users, then the person or scope — committed on its own, so a failure at the last step left the dependents gone while the person or scope survived, with a tombstone for an identity that still existed. The steps now run in one transaction, and a failure removes nothing; the audit entry recording the failed attempt is still written. Use cases 5 and 10 and the Data Retention Schedule say so (#212). - Deleting a scope (
DELETE /api/scopes/{id}) is now all or nothing, and repeating it finishes a cascade an earlier attempt left unfinished. The scope was flagged deleted first and its Users, Google Users and applications in separate saves after, so a failure part-way left a deleted scope with live members, and a retry, finding the scope deleted, skipped the cascade and answered200: those members could not sign in, but still showed in lists and counts, held their addresses, and never started the anonymisation window. The flag and the cascade now run in one transaction, and an already-deleted scope has the cascade run again for any member not yet deleted (AF-04b). The cascade is oneUPDATE … WHERE NOT is_deletedper kind of member instead of loading every member, password hash included; a hard delete of a scope reads only its members’ ids and removes them 2,000 to a statement, inside its one transaction: the 5,000 Users of a scope are deleted in one statement, and hard-deleted in three. Use cases 4 and 5 say so (#204). - A scope owner whose processing is restricted, or who has requested erasure, no longer counts as the co-owner
that lets another owner go (NFR-12). Neither can administer the scope, so removing an owner
(
DELETE /api/scopes/{scopeId}/owners/{personId}), deleting or hard-deleting a person, or suspending them for erasure could leave a scope whose only remaining owners could not act on it. These are now refused with409“This change would leave a scope without an owner.” (AF-22b, AF-09e, AF-10b), and an erasure request waits as blocked (AF-42d), as when the co-owner is deleted. The rule is in one shared guard, and NFR-12 and the use cases name it (#211). - Two requests that each take out one of a scope’s last two owners at the same moment can no longer both succeed
and leave the scope with no owner (NFR-12). Removing an owner, deleting or hard-deleting a person, an erasure
request and the anonymisation pass’s retry of a blocked one now make the last-owner check and their write in one
transaction that first locks the rows of the scopes concerned (
SELECT … ORDER BY id FOR NO KEY UPDATE, in id order, which does not hold up inserts of owners, members or applications into the scope), and deleting or hard-deleting a scope locks its row too, so a second such request waits for the first and is then refused with409(AF-22b, AF-09e, AF-10b) or blocked (AF-42d). The runtime login needs no new grant (#202). - The system now always keeps an active System Admin (NFR-32): one not deleted, not restricted and not waiting on
an erasure. Deleting or hard-deleting the last one (
DELETE /api/persons/{id},DELETE /api/persons/{id}/hard) is refused with409“This change would leave the system without an active System Admin. Add another System Admin first.” (AF-09f, AF-10d), where two administrators deleting each other at once both succeeded. The last one’s erasure request (POST /api/auth/erasure-request) is recorded with its deadline but blocked, as a last scope owner’s is (AF-42e), and the anonymisation pass releases it once another active System Admin exists. Each deletion and erasure holds one transaction-scoped advisory lock on the System Admin set, taken after the scope row locks, and decides on the person’s role and ownerships as read under the locks. A restriction on processing is never refused, andPUT /api/persons/{id}never demotes a System Admin (#203). - Editing a person (
PUT /api/persons/{id}), promoting a User to scope owner (POST /api/scopes/{scopeId}/users/{personId}/promote), updating a scope (PUT /api/scopes/{id}) and deleting a scope (DELETE /api/scopes/{id}) now write only the columns they change, where each saved the whole row it had read. An edit or a promotion wrote the name back as read, over a rename made in between, and an edit still renamed a person an erasure request had suspended in between; that edit is now answered404, as for any deleted person. A scope update or deletion wrote Google sign-in, self-registration and the email links back as read, undoing a change their own endpoints made in between, and a scope update revived a scope deleted in between; that update is now answered404. A person’s restriction, erasure, deletion and lockout columns were already written only by their own handlers (#237); tests now interleave a restriction and an erasure request with every writer that reads a person before writing it — password reset and email verification, deletion and adding an owner among them — and show that both survive. Setting Google sign-in, self-registration or the email links (PUT /api/scopes/{id}/google-signin,…/self-registration,…/email-links) likewise writes only that setting, so none undoes another’s or the scope update’s, and a scope deleted in between is answered404rather than revived. A name taken by another scope in between is answered with AF-03b’s own409message. A Google User’s deletion, erasure-request, restriction and anonymisation columns are now written only by targeted writes, as a person’s are: deleting, restricting, lifting a restriction on or requesting erasure for a Google User, Google sign-in’s refresh ofEmailVerified, and the anonymisation and re-erasure passes each write only their own columns, under the state they read. A sign-in whose account was deleted in between is refused401, and the anonymisation pass leaves a Google User restricted since it was selected as it stands. A name-onlyPUT /api/persons/{id}is answered409when the person’s role changed in between (#205). - Aborting a read request now stops its query. The controllers handed the mediator no cancellation token, and the
query handlers passed it on to neither validation nor EF Core (25 called
ValidateAsyncwithout it), so an abandonedGET /api/audit-logwith a wide filter ran to completion on the server. Every query endpoint now passesHttpContext.RequestAborted, and the Query project’s validators, lookups and pagination forward it; commands stay uncancellable by design, so a write that has begun still finishes or rolls back as one unit. A cancelled request is dropped quietly by the exception middleware, as before. Rule CA2016 now fails the build where a method holding a token calls an overload that would take it (#219).
Changed
- The target framework,
NullableandImplicitUsingsare now set once, insrc/Directory.Build.propsandtests/Directory.Build.props, instead of in each of the twelve project files, and nullable warnings and CA2016 are errors.global.jsonpins the SDK band (10.0.113,rollForward: latestFeature), and the Dockerfile’ssdkandaspnettags are pinned to the patch the floating10.0tag resolved to (10.0.402 and 10.0.13), so an image rebuild no longer changes toolchain on its own (#230). - Every CI job now has a
timeout-minutes(60 for the tests, 15 for the container build, 10 for the rest), so a hung Testcontainers pull or a deadlocked test no longer holds a runner and its concurrency group for six hours.tests.ymlnow declarespermissions: contents: readat the top, and the workflows that build the code take the SDK fromglobal.jsonrather than a floating10.0.x(#218). - CI now fails a pull request whose entity or map changes were committed without a migration:
tests.ymlrunsdotnet ef migrations has-pending-model-changesafter the build, with a placeholder connection string since the command reads no database. A newData.Teststest also migrates a PostgreSQL container to the latest migration, runs everyDownback to an empty database and applies the chain again, so a rollback that no longer runs is found before it is needed (#222). - FluentValidation is updated from 12.1.1 to 12.2.0 and Microsoft.NET.Test.Sdk from 18.10.1 to 18.11.0 (#231).
- The detailed health check logs the exception behind “Database: Unhealthy” at Warning, so the cause is in the log and not only the status (#226).
- Promoting a Scope Admin to System Admin (
PUT /api/persons/{id}withroleId1) keeps their scope ownerships, as FR-SC-08 lets a System Admin be a named owner, and removes only a scope membership. The promotion used to delete the ownership rows, so the applications the person owned were left with an owner who owned no scope, and it was refused with409when the person was a scope’s only owner (AF-08g); it is now accepted, and AF-08g is retired. FR-PE-10 and UC-08 step 5 say a System Admin is a member of no scope but may own scopes (#224). - The WebApi functional tests seed scopes and persons through one helper,
Support/Seeds.cs, instead of a copy in each of about seventy test files, so a new required column or trigger rule on either table is fixed in one place. Every seeded person shares one salted Argon2id hash of the test password, derived once per process, where each had its own, which cuts the suite’s time and memory. No test now sleeps to wait for a background worker: the queue-drain waits poll a condition up to a deadline withSupport/Poll.cs(the one remaining fixed wait is deliberate, the scheduled-pass check that no run follows a stop), and the credential list test seeds its two credentials with explicit creation times (#217, #228). - The operations guide now covers what an operator needs for upgrades and alerting (#220): migrating big tables
(
CREATE INDEX CONCURRENTLYthroughsuppressTransaction,NOT NULLwith a default, batched backfills, and what the per-row public-id triggers cost a bulk update), that the container image applies migrations on start unlessHEIMDALL_RUN_MIGRATIONS=falseand how that fits the owner connection string, the retention throughput ceiling with the formula and how to see a backlog, and the log lines and metrics to alert on. CONTRIBUTING.md carries the matching migration rules. A “Query costs to watch” section records three data-dependent query costs (name filters, the audit listing’s count, the per-request liveness read) and when to act on each (#229).
4.1.0 - 2026-10-21
Upgrading
- Operators: nothing changes until you opt in. Without
HEIMDALL_MIGRATIONS_CONNECTIONSTRINGthe entrypoint migrates, and the API runs, as the one login it always has. To make the audit triggers hold against the API’s credentials, separate the roles with the operations guide’s “Separating the migration owner from the runtime login”: back up, runscripts/sql/separate-owner.sql, set the owner’s password, store the owner’s connection string asHEIMDALL_MIGRATIONS_CONNECTIONSTRING, redeploy, and runscripts/sql/check-runtime-login.sql. Once a database is separated, a container that migrates without the variable fails, so it is stored before the next deploy. The API, the HTTP API and the OpenAPI document are unchanged.
Added
scripts/sql, the database roles of NFR-29 as psql scripts, parameterised by the variablesowner,runtimeanddatabase, holding no password, and safe to run again.new-deployment.sqlcreates an owner role, a runtime login, the database owned by the owner, theheimdallschema and the grants.separate-owner.sqlmoves a database the API’s login owns to a new owner in one transaction, with a five-secondlock_timeout: the database, thenREASSIGN OWNEDfor the schema, tables, sequences, trigger functions and migration history. Both shareruntime-grants.sql, which revokes everything the login holds and grants againSELECT,INSERT,UPDATEandDELETEon theheimdalltables,audit_logwithoutDELETE, sequenceUSAGEandSELECT, andSELECTon the migration history, with default privileges for the owner so tables later migrations add are granted as they are created. They refuse, changing nothing, a login that is a superuser, may create roles, is a member of the owner or still owns anything; a database that belongs to another role; and, forseparate-owner.sql, a login that also owns another database or a tablespace, whichREASSIGN OWNEDwould move too.check-runtime-login.sql, run as the runtime login, fails listing every statement it is not refused for want of privilege, and rolls everything back.scripts/migrations.pymigrates withHEIMDALL_MIGRATIONS_CONNECTIONSTRINGwhen the chosen environment file sets it, as the entrypoint does, and withHEIMDALL_DATA_CONNECTIONSTRINGotherwise.
Changed
- The deployment guide, the README and the operations guide create each database with
scripts/sql/new-deployment.sqlinstead ofcreatedb --owner heimdall_svc, and the operations guide’s “Database roles” now has the runbook for an existing deployment. The env templates anddocker-compose.ymlpoint at the scripts. - The functional suite sets its database up with
new-deployment.sqland runs the API as the runtime login, so a privilege the grants lack fails it.
Security
- TH-18 holds in the two-role deployment: the API’s login owns nothing, and cannot disable, drop or replace the
audit triggers or their functions, nor delete or truncate audit rows.
DatabaseRoleTestsapplies both scripts to PostgreSQL 18, the second to a database migrated the old way, and is refused each of those statements as the runtime login for want of privilege. In a single-login deployment, where the API’s login owns the tables, the guarantee still does not hold; the Threat Model rates that Medium.
4.0.1 - 2026-10-21
Changed
- The operations guide’s “Ending the overlap” and the deployment guide’s troubleshooting table now say that a
container started from the 4.0 compose file never sees
HEIMDALL_AUTH_TOKEN_SECRETor_PREVIOUS, so it never logs{Variable} is set but no longer read; remove it. Under yggdrasil, check the store withygg vars list <scope> --keysinstead.
Fixed
- The deployment guide’s PowerShell recipe for the signing key now deletes
signing.pemonce it is encoded, so the private key is not left on disk. The bash recipe pipes the key straight tobase64and never writes the file.
4.0.0 - 2026-10-20
Upgrading from 3.x to 4.0
- Operators: remove
HEIMDALL_AUTH_TOKEN_SECRETand_PREVIOUS; they are no longer read, and a warning names them while set. Remove them from the environment after this release. In yggdrasil, fortuna-api’sFORTUNA_AUTH_TOKEN_SECRETis a${ref:}to Heimdall’s value, so Fortuna’s references are removed first. Nothing else changes:HEIMDALL_AUTH_SIGNING_KEYsigns as before. - Consumers: HS256 tokens are refused; verify through
/.well-known/jwks.json(fortuna-api 4.0.0 does). Heimdall has issued none since 3.0.0. - Rolling back: to 3.x needs nothing, since 3.x does not require the secret; without it, 3.x refuses HS256 tokens too.
Changed
- The operations guide’s “Bounding a distributed flood at the edge” now describes the credential-route limit
yggdrasil 0.7.4 ships: the
heimdall-api-<environment>-authrouter covers/api/auth/on both hosts for every method butGET,HEADandOPTIONS, 10 a minute with a burst of 10 per source. The relay router it proposed for Fortuna is gone: a client system on the same host callshttp://heimdall-api.<environment>:8080/and never crosses the edge, as fortuna-api 3.1.0 does. A new paragraph tells the edge’s429(plain text,X-Retry-In) from the API’s (JSON, noX-Retry-In). Trusted caller headers may also travel over a private network nothing public routes to. The HTTP API and the OpenAPI document are unchanged.
Removed
HEIMDALL_AUTH_TOKEN_SECRETandHEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS: the compose file no longer passes them, and start-up no longer reads or checks them, so a value 3.x refused to start with no longer stops it. While either is set, start-up logs{Variable} is set but no longer read; remove itat Warning, naming the variable and never its value.- The overlap after the switch to ES256: no reader (bearer, two-factor challenge, application) accepts an HS256
token any more, whatever it is signed with, and the
Verified a legacy HS256 {Reader} tokenline is gone. The start-up lineTokens: signed ES256 …; HS256 tokens {LegacyState}keeps its template, withrefused.
Security
- TH-43 closed: the shared signing secret no longer exists. Only Heimdall holds a key that can sign its tokens, and consumers hold public keys only.
3.0.0 - 2026-10-19
Upgrading from 2.x to 3.0
- Operators: set
HEIMDALL_AUTH_SIGNING_KEYbefore deploying, one key per environment: generate it withopenssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out signing.pemand set the variable tobase64 -w0 signing.pem. It is required: start-up refuses to run without a P-256 private key there, naming the variable and never its value. KeepHEIMDALL_AUTH_TOKEN_SECRETand_PREVIOUSas they are through the overlap: while set, the HS256 tokens issued before the upgrade stay valid. Remove them no earlier than 24 hours after the production deploy, and only once every consumer verifies through the key set and no legacy token has been verified for the longest token lifetime; 4.0.0 stops reading them. In yggdrasil, fortuna-api’sFORTUNA_AUTH_TOKEN_SECRETis a${ref:}to Heimdall’s value, so Fortuna’s references are removed first, and its 4.0.0 release must stop reading the secret, before Heimdall’s value is unset (the 4.0.0 procedure, Part E). - Consumers: every token (person, subject-rights-only, two-factor challenge and application) is now signed
ES256 and carries a
kid, the RFC 7638 thumbprint of its key. Claims, issuer, audiences and lifetimes are unchanged. Verify tokens with the keys atGET /.well-known/jwks.json(inside yggdrasilhttp://heimdall-api.<environment>:8080/.well-known/jwks.json; anyone elsehttps://<PUBLIC_HOST>/.well-known/jwks.json), accept ES256 only, and fetch the set again, at most once a minute, when a token names akidyou do not know. A consumer that verifies HS256 with the shared secret refuses every token issued after this upgrade. - alexandria-api and cerberus-api verify HS256 only (alexandria-api#136, cerberus-api#120), so they cannot verify 3.0.0 tokens. Both are out of scope for this release and neither is deployed against Heimdall.
- Deploy order: fortuna-api 3.0.0 or later first.
- Rolling back: rolling Heimdall back to 2.x signs out everything 3.0.0 issued: person and subject-rights
sessions, challenges in flight, and application tokens. At Fortuna 3.0.0, ES256 tokens keep verifying from its
cached key set until they expire. After a Fortuna restart the key-set fetch fails and they are refused. HS256
tokens issued after the rollback verify through Fortuna’s legacy secret. Fortuna must stay at 3.0.0 or later
while Heimdall runs 3.x. A rollback to 2.x is possible only while
HEIMDALL_AUTH_TOKEN_SECRETis still set, because 2.0.0’s compose requires it.
Added
GET /.well-known/jwks.json: the public ES256 keys, as a JSON Web Key Set (RFC 7517, not the usual envelope), anonymous, under the anonymous rate limit, withCache-Control: public, max-age=300. It lists the current key and, during a rotation, the previous one.HEIMDALL_AUTH_SIGNING_KEY(required) andHEIMDALL_AUTH_SIGNING_KEY_PREVIOUS(optional): base64 of a P-256 PKCS#8 PEM. The previous key is only published and accepted, never used to sign.
Changed
- Tokens are now signed ES256 and carry a
kid; claims, issuer, audiences and lifetimes are unchanged. HEIMDALL_AUTH_TOKEN_SECRETis optional. While it is set (with_PREVIOUS), HS256 tokens signed with it are still accepted, but none is issued, and each accepted one is logged without the token or the identity._PREVIOUSwithout the secret stops start-up.
Security
- Only Heimdall holds a key that can sign its tokens (Threat Model TH-43; the threat closes when 4.0.0 removes the
shared secret). Each key verifies its own algorithm only, so a token’s
algheader never selects the kind of key.
2.0.0 - 2026-10-19
Upgrading from 1.x to 2.0
- Clients: a Scope Admin’s
POST /api/scopes/{scopeId}/owners/{personId}answers202withinvited: trueand an invitation instead of201and an added owner; the person becomes an owner only when they accept. Treat202(orinvited: true) as “invited”, not “added”. A System Admin’s request is unchanged. - Deploy order: deploy the heimdall-ui release that carries scope invitations before this one.
- Operators: nothing is required.
HEIMDALL_SCOPE_INVITATION_LIFETIME_IN_SECONDSis optional (default 7 days), and the one migration only adds thescope_owner_invitationtable, covered by the runtime role’s schema-wide grants.
Added
- Scope owner invitations. New endpoints:
GET /api/scopes/{scopeId}/owner-invitationsandDELETE /api/scopes/{scopeId}/owner-invitations/{invitationId}(204) for the scope’s owners and System Admins, to list and withdraw open invitations;GET /api/persons/me/scope-invitations,POST /api/scope-invitations/{invitationId}/acceptand…/declinefor the invited administrator. Accepting ends the invitation and creates the ownership in one transaction. New messages: “The invitation was sent.” (202), “The invitation was not found or is no longer open.” (404, for an unknown, ended, expired or somebody else’s invitation, and for one voided because its scope was deleted or its invitee is no longer an eligible administrator), “The invitation was accepted.” and “The invitation was declined.” (200). HEIMDALL_SCOPE_INVITATION_LIFETIME_IN_SECONDS, optional: how long an invitation can be accepted, 604800 (seven days) by default, 3600 to 2592000; anything else falls back to the default.- A migration,
AddScopeOwnerInvitation, adds thescope_owner_invitationtable, whosepublic_idis guarded like the other entity tables’. Invitations are purged 90 days after they ended, or 90 days after they expired if they never ended. Each invitation created, withdrawn, accepted, declined or voided is audited. - An invitation notice and an answer notice, neither with a link or token. The invitee is mailed “
invited you to co-own the scope . Sign in to Heimdall to accept or decline. The invitation expires on .”; the notice is skipped for a repeat while the invitation is open, for someone invited within HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDSand for someone whose processing is restricted. The inviter is told of either answer, unless they are deleted, anonymised or processing-restricted. - Erasing a person voids the unended invitations made to those anonymised in that run, clears them as inviter, and audits each void; every scheduled run also catches up anonymised persons still named.
Changed
- Behaviour change: a Scope Admin’s
POST /api/scopes/{scopeId}/owners/{personId}used to answer201and add the owner at once. It now answers202“The invitation was sent.” withinvited: true,invitationIdandinvitationExpiresAt, writes no ownership, and opens an invitation the person must accept; a person who already owns the scope still gets200“Person is already an owner of this scope.”. Clients: a client that treats any2xxfrom this endpoint as “added” must change; treat202as “invited”. A System Admin’s request is unchanged:201, or200for an existing owner, withinvited: false. The endpoint now shares the account-creation rate limit (thirty a minute per administrator and per address) for every caller, a System Admin included. Deploy order: deploy the heimdall-ui release that carries scope invitations before the heimdall-api release that carries them: an older UI shows the202as a plain success while the person does not appear among the owners. A Scope Admin who owns no live scope cannot sign in (AF-11e), so cannot accept an invitation; a System Admin adds such a person directly. Operators: no setting is required; the runtime role’s schema-wide grants and default privileges cover the new table.
Fixed
- An audit log entry that fails to save is now logged as a warning, naming its action and the database error. The repository reports a failed insert in its result rather than throwing, and the writer discarded that result, so the entry was lost without a trace. The request’s answer is unchanged.
Security
- A Scope Admin can no longer make another existing administrator a co-owner of a scope on their own: the ownership starts only when that person accepts, signed in as themselves. This closes the acceptance residual of Threat Model TH-41 (residual closed); the threat itself stays on the register.
1.7.0 - 2026-10-19
Fixed
- A person’s lifecycle columns are no longer written by whole-row saves (#237). The address and its verified flag,
the role and scope, the login lockout, and the deletion, erasure-request, restriction and anonymisation columns are
each written by a conditional write of their own, so a request that read the person before another changed them
can no longer put the earlier values back: a profile edit no longer undoes a restriction, a deletion or a lockout
made meanwhile. Adding and removing a scope owner now write only the ownership row, and removing one re-checks
that a live co-owner remains in the same statement. Clients: only requests that race another change to the
same person answer differently. An address or role change through
PUT /api/persons/{id}or a promotion throughPOST /api/scopes/{scopeId}/users/{personId}/promotewhose person changed meanwhile answers409with “Concurrency conflict: the record was modified or removed by another process.”; a restriction, lift, erasure request, deletion or ownership change that lost such a race answers as the request that won leaves it, with the existing message for that state.
Security
- A Google ID token is accepted once (FR-GO-21). After it is verified, and before anything is created or issued on its
strength (at sign-in, before any scope or account is read; for an erasure request, once its subject has been
compared with the Google User’s), its use is recorded under the SHA-256 digest of its
jticlaim (of its signed part, the header and payload, when it has none) in the newgoogle_id_token_usetable; a token already recorded is refused exactly as an invalid one, and of two simultaneous presentations one is refused. The records are purged with the single-use tokens once the token has expired and the grace period has passed. This closes the replay of a copied token within its hour of validity (Threat Model TH-19). Clients: obtain a new ID token from Google for every call that takes one, a retry included: a verified token is spent even when the call then fails, for example with a403(scope unavailable, unverified address), a409or a5xx. Presenting a token a second time — toPOST /api/auth/google, or as the proof of presence ofPOST /api/auth/erasure-request— now answers401with “Google authentication failed.” or “The credential presented was not accepted.” respectively. The optionalnonceis unchanged. Operators: the migration adds one table; the runtime role’s schema-wide grants and default privileges cover it, so no grant changes.
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 <[email protected]>, 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.