Skip to main content

Configuration

Configuration sources​

The API reads its settings from these sources, each overriding the one before (Wayd.Web.Api/Configurations/ConfigureServices.cs):

  1. appsettings.json, then appsettings.{Environment}.json.
  2. One file per concern in Wayd.Web/src/Wayd.Web.Api/Configurations/ — logger.json, hangfire.json, cors.json, database.json, middleware.json, security.json, openapi.json, securityheaders.json — each followed by its optional {name}.{Environment}.json overlay (database.Development.json, hangfire.Development.json).
  3. User secrets (dotnet user-secrets, in Wayd.Web/src/Wayd.Web.Api).
  4. Environment variables, with __ in place of : (DatabaseSettings__ConnectionString).

Database​

Database settings live under DatabaseSettings in:

Wayd.Web/src/Wayd.Web.Api/Configurations/database.json

The base file holds an empty connection string; database.Development.json supplies the local one and turns on EnableSensitiveDataLogging. See Prerequisites → Database.

SettingDefaultWhat it does
DBProvider—mssql. The only provider wired up.
ConnectionString—Where the application's data lives.
EnableSensitiveDataLoggingfalseIncludes parameter values in EF Core's command logging. On in Development only — a failed bulk write otherwise logs every parameter of every row.
CommandTimeoutSeconds30How long any one command may run.

Command timeout​

The ceiling belongs to the context, not to each call site: a request-path write that takes half a minute is a fault worth surfacing, and raising the limit everywhere to suit the few operations that legitimately run long would hide it.

An operation that needs longer asks for it, for its own scope, through BaseDbContext.WithCommandTimeout, which restores the previous value when disposed. It only ever raises — a caller asking for less than the context already allows gets the context's value. Two callers today: the migration run, and the import runner, whose files are bounded by a row cap rather than by anyone waiting.

READ_COMMITTED_SNAPSHOT​

Wayd expects row versioning to be on. A save commits its records together with the audit trail, activity log and outbox envelopes that record them, which is a longer transaction than the entity write alone; with it off, a reader blocks behind that transaction, and polling a running import's status can time out while the import is doing exactly what it should.

Azure SQL Database has it on for every new database, so a deployment there already satisfies this. SQL Server and Azure SQL Managed Instance default it off — which is why a local container can behave differently from the deployed article, and be stricter rather than looser.

Startup checks it. In Development it is switched on automatically, so a fresh container, a new database name in user secrets, CI and the Testcontainers suites need no manual step. Anywhere else it logs a warning naming the database and the statement to run, because the change takes exclusive access and disconnects every other session:

ALTER DATABASE [your-database] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

It never prevents the application from starting. Wayd is correct without it — readers block rather than seeing a prior snapshot — so a login that cannot ALTER, or an engine that refuses while another session holds the database, gets a warning and a working application.

Authentication​

Wayd supports two authentication paths:

  • Local (username + password) — always available, configured per-deployment via JWT signing settings.
  • Identity providers (OIDC) — Microsoft Entra ID and any standards-compliant OIDC provider (Google, Okta, Auth0, Keycloak, …). Providers are stored in the database and managed by admins through the Settings UI; no app restart is needed to add, edit, enable, or disable one.
First deployment?

If this is a fresh deployment with no users yet, see First-Run Setup to create the initial administrator account before configuring identity providers.

Identity providers (DB-managed)​

Each row in the Identity.OidcProviders table is one configured provider — its discovery Authority, public ClientId, expected token Audience, scopes, and (for Entra) the allowed-tenant list. The token-exchange endpoint resolves provider metadata from this table on every request, with a short cache TTL and explicit invalidation on admin writes.

The database is the single source of truth for identity providers — there is no config-file or environment-variable equivalent. Providers are created and edited entirely through the admin UI.

Managing providers. Admins with the Permissions.OidcProviders.{View|Create|Update|Delete} permissions manage providers from Settings → Access → Identity Providers. The Test action in the list and Test discovery on a provider's page (both need Permissions.OidcProviders.Update) fetch the provider's .well-known/openid-configuration document so typos in Authority are caught at configuration time, not at the user's next login.

Provider fieldPurpose
NameStable key written into UserIdentity.Provider. Immutable post-create. Reserved value "Wayd" (local accounts).
DisplayNameHuman-readable label rendered on the login page.
ProviderTypeMicrosoftEntraId (multi-tenant via /common/, tenant allowlist) or GenericOidc (single-issuer, no tenant check). Immutable post-create.
AuthorityOIDC issuer URL. Must be https. Wayd appends /.well-known/openid-configuration for discovery.
ClientIdPublic OAuth client ID. Surfaced to the frontend so the OIDC client can be constructed.
AudiencePinned aud claim on incoming tokens. See Finding your Audience below.
ScopesOAuth scopes the frontend requests when initiating sign-in.
AllowedTenantIdsEntra only. Empty/null rejected at the entity level. Tokens whose tid (or issuer-derived tenant) is not in this list are rejected. Ignored for GenericOidc.
ClockSkewSecondsTolerance for expiry/not-before checks. Defaults to 60; range [0, 600].
IsEnabledDisabled providers are hidden from the login page; exchange attempts against them return 401.
AllowAutoRegistrationMaster switch for just-in-time user provisioning. Defaults to false (secure by default). When off, an unknown user signing in is rejected and must be pre-created by an admin. See User provisioning.
RequireEmployeeRecordWhen auto-registration is on, gates it to users whose email matches an existing employee. true (the safer default) lets only known employees self-register; false grants an account to anyone who can authenticate. Ignored when AllowAutoRegistration is off.
DefaultRoleIdRole assigned to users auto-created through this provider. Required when AllowAutoRegistration is on (no implicit fallback). Protected by a FK: a role pinned here cannot be deleted until the provider is repointed. Null/ignored when AllowAutoRegistration is off.

Adding a Microsoft Entra ID provider. From Settings → Access → Identity Providers, choose Create Identity Provider, pick Microsoft Entra ID as the provider type, and fill in:

FieldValue
Authorityhttps://login.microsoftonline.com/{your-tenant-id}/v2.0 for a single tenant; https://login.microsoftonline.com/common/v2.0 only for true multi-tenant deployments where users from multiple Entra tenants sign in (/v2.0 suffix required).
ClientIdThe client (SPA) app registration's client ID, presented to Entra when initiating sign-in.
ScopesThe standard OIDC scopes plus your API scope, e.g. openid profile email api://{api-client-id}/access_as_user — the API scope makes Entra issue an access token with aud = {api-client-id}.
AudienceThe API app registration's client ID, pinned as the expected aud claim. See Finding your Audience below.
AllowedTenantIdsThe Entra tenant ID(s) permitted to sign in. Must be non-empty.

The client ID and API client ID are different values because Wayd follows Entra's standard two-registration pattern (see Entra App Registration Setup).

Setting up a new environment

If you don't yet have an Entra app registration for this deployment, follow Entra App Registration Setup first. That walks through creating the two app registrations (API + client), choosing the right token version, and collecting the GUIDs referenced above.

Finding your Audience. When entering the Audience in the Settings UI, the aud claim shape depends on the token version your app registration issues:

  • v2.0 tokens (api.requestedAccessTokenVersion: 2 in the app manifest): aud is the bare <ClientId> GUID. This is what Wayd expects.
  • v1.0 tokens (the default for older registrations, or when api.requestedAccessTokenVersion is null/1): aud is api://<ClientId> or api://<ClientId>/access_as_user.

Don't guess — decode a real token at jwt.ms and copy the aud value verbatim. A mismatch here rejects every exchange attempt with a 401, which is hard to diagnose after the fact. If you're seeing api:// in a token you expected to be v2-shaped, flip api.requestedAccessTokenVersion: 2 per the app registration guide.

Local Authentication​

Configure in Wayd.Web/src/Wayd.Web.Api/Configurations/security.json or User Secrets:

{
"SecuritySettings": {
"LocalJwt": {
"Secret": "<strong-random-secret-at-least-32-chars>",
"Issuer": "https://wayd.dev",
"Audience": "https://api.wayd.dev",
"TokenExpirationInMinutes": 60,
"RefreshTokenExpirationInDays": 7
}
}
}

Only Secret needs to be set per deploy — the rest have sensible defaults and in particular Issuer / Audience are JWT claim identifiers, not environment-specific config. Don't override them per environment.

The checked-in security.json holds a placeholder secret beginning DEV-ONLY-SECRET. A host outside Development refuses to start while the secret is empty or still that placeholder.

Data Protection (at-rest secret encryption)​

Connector credentials (Azure DevOps PATs, AI API keys, future OAuth refresh tokens) are encrypted at rest before being written to the Connections table. Encryption uses AES-256-GCM with a per-row random nonce; the master key lives in configuration.

Configure in Wayd.Web/src/Wayd.Web.Api/Configurations/security.json or User Secrets:

{
"SecuritySettings": {
"DataProtection": {
"MasterKey": "<base64-encoded 32-byte AES key>"
}
}
}

Generate the key with:

openssl rand -base64 32

The host refuses to start when the key is missing, is not base64, or does not decode to exactly 32 bytes. The key checked into security.json is for development only and is not rejected elsewhere, so a deployment must override it.

User Secrets for local dev:

cd Wayd.Web/src/Wayd.Web.Api
dotnet user-secrets set "SecuritySettings:DataProtection:MasterKey" "$(openssl rand -base64 32)"
Key loss is unrecoverable

This is a data-encryption key, not a signing key. If you lose or change it, every connector credential stored in the database becomes undecryptable AEAD garbage — customers must re-enter every PAT and API key. Treat it as write-once for the lifetime of the data. Keep a backup outside the deployment system (password manager, sealed vault).

This is also why MasterKey must be separate from LocalJwt:Secret — JWT secrets are cheap to rotate (sessions die, nothing else); data keys are not. Per NIST SP 800-57 §5.2, "a key shall be used for only one purpose."

In-product key rotation is tracked as a roadmap item (#595) — until that lands, the only rotation procedure is "rotate the key, force every customer to re-enter their credentials." Avoid rotating unless you're responding to a confirmed compromise.

For Azure deployments, the key is wired as the dataprotection_master_key Terraform variable (sourced from the DATAPROTECTION_MASTER_KEY GitHub Actions secret) and bound to the backend Container App as the SecuritySettings__DataProtection__MasterKey env var.

Auth flow (high level)​

Every login path produces a Wayd JWT + Wayd refresh token stored in localStorage/sessionStorage. The API only ever validates Wayd JWTs — OIDC tokens from upstream providers are exchanged for a Wayd JWT at login and never presented to the API directly.

Permissions travel as permission claims inside the Wayd JWT — no separate /permissions round-trip. An admin permission change takes effect on the user's next refresh (within the access-token TTL).

Sessions​

Each sign-in writes a row to Identity.UserRefreshTokens. One row per concurrent session, so a second device does not displace the first.

The row stores a salted hash of the refresh token, never the token itself, verified with CryptographicOperations.FixedTimeEquals. Because every hash carries its own salt there is no equality lookup: resolving a token means verifying it against each of that user's live rows, which is a handful. It also records a coarse device label parsed from the User-Agent and the remote address — both display-only, so a person can recognise their own sessions. Nothing authorizes on them.

Refresh rotates the token and retains the superseded hash for five minutes. Presenting a superseded token within that window is reuse, and revokes that one session — not the user's other devices. Outside the window it is indistinguishable from any other stale value and is simply rejected.

Sign-out revokes the session named by the refresh token the client sends. When that token is absent or matches no live session, nothing is revoked: "which session is this?" having no answer is not a reason to end them all, and treating it as one made a single sign-out destroy every device a user had. POST /api/auth/logout-all is the explicit way to end everything.

The same five-minute grace applies when resolving a token to its session, not just when detecting reuse. A background refresh can land between the client reading its stored token and using it; if the two lookups disagreed about what identifies a session, a caller holding a seconds-old token would look like a stranger.

note

Revocation is not immediate. Access tokens are self-contained JWTs validated by signature and never looked up, so a revoked device keeps working until its token expires (TokenExpirationInMinutes).

Sessions are created only by interactive sign-in. Personal access tokens authenticate through PersonalAccessTokenAuthenticationHandler and never touch this table.

Users manage their own sessions on Account → Profile → Sessions. See Your Profile.

The login page consults GET /api/auth/providers (anonymous, cheap) to decide which provider buttons to render. The response is:

{
"local": true,
"oidc": [
{
"name": "MicrosoftEntraId",
"displayName": "Microsoft Entra ID",
"providerType": "MicrosoftEntraId",
"authority": "https://login.microsoftonline.com/common/v2.0",
"clientId": "{public client ID}",
"scopes": ["openid", "profile", "email"]
}
]
}

Only enabled providers appear. AllowedTenantIds and any future server-side secrets are deliberately not exposed — they're security gates, not client config.

Identity model​

UserName is address-shaped, and validated as such

A locally created account takes UserName from the email it was created with; an Entra account takes it from the UPN. Both are addresses or address-shaped identifiers, so the allowed-username character set is the email grammar — IdentityOptions.User.AllowedUserNameCharacters is assigned EmailAddress.AllowedCharacters, which is RFC 5322 atext plus . and @.

This matters because the two rules are applied a step apart by different components: the create-user command validates the value as an email, then Identity validates the same string as a username. Any character one accepts and the other rejects makes the account impossible to create, and the error talks about a username the caller never supplied. The apostrophe was such a character until it was fixed — o'brien@example.com is a valid address, and nobody with that surname could be given an account.

Widening it further is safe only for characters an address may actually contain. Whitespace and quoted local parts are legal in an address and still excluded: a username has to survive being compared, normalized and stored as one token.

User → login-provider linkage lives in a UserIdentity table. One row per (user, provider-identity) pair, keyed by (Provider, ProviderTenantId, ProviderSubject):

Every authentication path resolves a user through the same lookup, regardless of provider:

var identity = db.UserIdentities.SingleOrDefault(i =>
i.IsActive &&
i.Provider == provider &&
i.ProviderTenantId == tenantId &&
i.ProviderSubject == subject);

Provider-specific logic is confined to how the incoming credential is validated (OIDC token vs. username/password) and what's used as the subject, not how the row is resolved. For Microsoft Entra ID the subject is oid and the tenant is tid; for GenericOidc providers the subject is sub and ProviderTenantId is null (single-issuer providers have no tenant concept); for local users the subject is ApplicationUser.Id and ProviderTenantId is null.

Invariants:

  • Filtered unique index on (Provider, ProviderTenantId, ProviderSubject) WHERE IsActive = 1. NULL tenants are distinct under SQL Server's filtered-unique-index semantics, so local users (which have ProviderTenantId = NULL) coexist with Entra rows that haven't yet had their tenant populated.
  • At most one active UserIdentity per ApplicationUser at rest. Every authenticable user has exactly one; pre-provisioned-but-not-yet-linked users (e.g., admin-created Entra users who haven't signed in via SSO yet) can have zero.
  • This invariant is enforced in application code via IUserIdentityStore.DeactivateAllActive, not at the schema level. Any write path that adds a new active row must first deactivate any prior active rows, setting UnlinkedAt + an UnlinkReason.
  • Multi-provider account linking (one user, multiple active identities) is deliberately not supported. Relaxing the invariant later is a forward-compatible change; retrofitting it after multiple-active rows hit prod would not be.

Local users in UserIdentity: local (Wayd) users get a row with Provider = "Wayd", ProviderTenantId = NULL, ProviderSubject = ApplicationUser.Id.ToString(). The stable ApplicationUser.Id is the subject — not the username, which is mutable. The local-login flow resolves by username first, verifies the password, and then asserts that an active Wayd identity row exists. That last check enables "disable local login for this specific user" by deactivating the identity row — no separate flag needed.

ApplicationUser.LoginProvider is retained as a "primary provider" indicator (it mirrors the active UserIdentity.Provider) so code that needs a cheap provider check doesn't have to join.

User provisioning (auto-registration)​

When a sign-in resolves no active UserIdentity (and no pending migration applies), the exchange reaches the create-new-user path. Whether a user is actually created there is governed by a per-provider registration policy stored on the OidcProvider row: AllowAutoRegistration, RequireEmployeeRecord, and DefaultRoleId. The policy is read from the provider via the registry on every first-time sign-in.

Decision flow for a new (unresolved) user:

new user signing in via provider P
├─ first user ever? ──────────────► create, assign Admin (bootstrap — ignores policy)
├─ P.AllowAutoRegistration == false ─► reject (403) "Registration is disabled for this identity provider"
├─ P.RequireEmployeeRecord == true
│ and no matching employee ───────► reject (403) "Registration is restricted to users with an employee record"
└─ otherwise ───────────────────────► create, assign P.DefaultRoleId (required when enabled)
  • Secure by default. AllowAutoRegistration defaults to false. A freshly configured provider does not auto-provision until an admin opts in — including providers that predate this setting (the migration backfills existing rows to false, so they stop auto-registering on upgrade until re-enabled).
  • First-user bootstrap is exempt. The very first user to sign in to an empty deployment is always created and made Admin, regardless of policy — a fresh install can never lock itself out. (Self-hosted first-run uses the bootstrap-token flow instead; see First-Run Setup.)
  • Employee-record gate. Historically registration was restricted to users whose email matched an Employee record. That gate is now RequireEmployeeRecord, defaulting to true. Turning it off is a deliberate loosening: anyone who can authenticate through the provider gets an account.
  • Default role. Auto-created users are assigned DefaultRoleId (any role — system or custom). It is required when auto-registration is enabled — there is no implicit fallback; an enabled policy always names a role. The reference is protected by a foreign key with NO ACTION on delete, so a role that's pinned as a provider's default cannot be deleted until the provider is repointed; RoleService surfaces this as a clear conflict before the constraint fires. The FK plus that delete guard keep the reference valid, so there is no fallback role — if the referenced role is somehow missing (e.g. a raw SQL delete), sign-in fails rather than silently provisioning an unintended role.

The policy is enforced in UserService.CreateOrUpdateFromPrincipalAsync. Generic-OIDC providers do not yet provision new users at all (unknown users are rejected); the policy is currently actionable on the Entra path.

Admin-created (pre-provisioned) users​

Auto-registration is the self-service path. The other way an account comes into being is an admin creating it ahead of the user's first sign-in (Settings → Access → Users → Create User).

A pre-created Entra user has no oid until they sign in, so nothing in the UserIdentity table can match their first token. A sign-in is never linked to an existing account by email or display name alone: those are mutable directory attributes, and linking on them let anyone who could set a matching email in an allowlisted tenant take the account over. Creating the user is what authorizes the link instead.

Creating an Entra user stages their first sign-in to one tenant. UserService.CreateAsync sets PendingMigrationTenantId to that tenant and PendingMigrationStagedAt to now, and writes no UserIdentity row (local users do get theirs immediately, keyed by ApplicationUser.Id). The tenant is:

  • the provider's only allowed tenant, when AllowedTenantIds has one — the form asks nothing;
  • the tenant the admin picks in the form's Sign-in Tenant field, when it has several (the request's TenantId, validated against the allowlist).

Creating an Entra user fails when no Entra provider is configured, since there is no tenant to stage and no way to sign in.

The first sign-in completes the link through the same pending-migration rebind a tenant move uses: it matches PendingMigrationTenantId == token.tid and the token's UPN (upn, else preferred_username, else email) against the user's UserName or Email, writes the UserIdentity row with that token's tid and oid, and clears the staged fields. Until then the user has zero identity rows — the "can have zero" case under Identity model — and shows a pending migration (in a multi-tenant provider's Active Migrations tab, with no source tenant).

Setting the sign-in tenant afterwards. For an Entra user with no active identity, the user page offers Set Sign-in Tenant (UserService.StageEntraSignInTenant, PUT /api/user-management/users/{id}/sign-in-tenant, Users.Update) instead of Cancel Pending Migration, since cancelling would only leave them unable to sign in. It stages or replaces the tenant with the same rules as creation, which covers a wrong tenant picked at creation and a user whose staged link was lost. It is refused for a user with an active Entra identity: someone signs in with that account, so moving it is a tenant migration. UserDetailsDto.HasActiveIdentity tells the page which case it is.

A sign-in from any other tenant, even with the same UPN, doesn't match the staged link. It reaches the create path, matches the existing account by UPN/email, and is refused with a 403 ("An account already exists for this identity but is not linked…") whatever the registration policy; no duplicate user is created.

Users created before staging existed. The Stage-Entra-First-Sign-In-For-Unlinked-Users migration stages every Entra user who has never had an identity row, when the provider allows a single tenant. Under a multi-tenant provider it can't choose the tenant; an admin sets it with Set Sign-in Tenant.

Identity migration​

Wayd supports three admin-initiated identity migration workflows. In all three cases the ApplicationUser.Id is preserved, so all downstream FKs (permissions, work items, audit) remain intact.

Entra tenant migration​

When an org moves users from one Entra tenant to another, an admin stages the migration in bulk from the provider page (Settings → Access → Identity Providers → [provider]). Both the Migrate Users to New Tenant action (in the page's Actions menu) and the read-only Active Migrations tab appear only for a MicrosoftEntraId provider with two or more AllowedTenantIds (there must be tenants to move between); the action additionally requires Permissions.Users.Update and the tab requires Permissions.Users.View. The action opens a modal where the admin picks a source and a target tenant (both drawn from the provider's allowlist), is shown the users currently bound to that provider+source-tenant with no migration already pending, selects up to 500, and stages them in one action. Each user's rebind completes automatically when they next sign in from the new tenant — the deferred mechanism below is unchanged. The Active Migrations tab lists every user with a staged-but-incomplete migration (source/target tenant and when it was staged).

Staging sets PendingMigrationTenantId (the target Entra tenant GUID) and PendingMigrationStagedAt on each selected ApplicationUser. The bulk command (UserService.StageBulkTenantMigration) re-validates each user server-side — still Entra, still on the source tenant, no pending migration — so a stale selection can't stage the wrong users; it returns a per-user staged/skipped summary, and the whole batch runs in one transaction (a mid-batch failure stages nobody).

A staged migration can be cancelled per user before it completes — UserService.CancelTenantMigration (DELETE /api/user-management/users/{id}/stage-migration, surfaced as Cancel Pending Migration on the user detail page) clears both PendingMigrationTenantId and PendingMigrationStagedAt. It's idempotent. There is no bulk-cancel; the provider's Active Migrations tab is read-only.

During Entra token exchange, UserService.ResolveUserByEntraIdentity tries lookups in order: FindActive(provider, tid, sub) → null-tid upgrade (one-time tenant population for backfilled rows) → pending-migration rebind → fall through to create-new-user (subject to the registration policy). The rebind path matches a user by PendingMigrationTenantId == token.tid AND LoginProvider == MicrosoftEntraId AND (NormalizedUserName == token.upn OR NormalizedEmail == token.upn). On match, inside one transaction:

  1. Deactivate all active identity rows (UnlinkReason = TenantMigration).
  2. Insert a new active row with (MicrosoftEntraId, token.tid, token.sub).
  3. Clear PendingMigrationTenantId and PendingMigrationStagedAt (the two always move together).

If staging is skipped and the user signs in from the new tenant first, the token matches their existing account only by UPN/email, which never links: the sign-in is refused with a 403 and the account is untouched, so staging the migration afterwards still works. (A new-tenant UPN that matches no existing account reaches the create path instead, and with auto-registration on becomes a separate ApplicationUser — there is no "merge users" UI to recover from that.)

Cross-provider migration (OIDC→OIDC or Wayd→OIDC)​

An admin can move a user from one OIDC provider to another, or from a local (Wayd) account to an OIDC provider. As with tenant migration, the rebind is deferred — the user's current login continues to work until they sign in via the target provider.

PendingMigrationProviderId is set on ApplicationUser to the target provider's Name. UserService.TryApplyPendingProviderMigration checks for a pending provider migration after the normal identity lookup fails, and is wired into both token-exchange paths: ResolveFromGenericOidcPrincipalAsync for a GenericOidc target, and ResolveUserByEntraIdentityAsync (after the tenant-migration check) for a Microsoft Entra ID target. The rebind matches a user by PendingMigrationProviderId == providerName AND a case-insensitive match of the token's identifier against either NormalizedUserName or NormalizedEmail — either side is enough, which matters for a local account whose username is not their email address. The identifier is the email claim on the GenericOidc path, and the resolved UPN (upn, else preferred_username, else email) on the Entra path. A token carrying email_verified=false is rejected on both paths: its identifier is discarded before lookup, so no staged migration can match it.

On match, inside one transaction:

  1. Deactivate all active identity rows (UnlinkReason = ProviderRelinked).
  2. Insert a new active row with (providerName, providerTenantId, token.sub) — providerTenantId is the token's tenant for Entra, null for GenericOidc.
  3. Update LoginProvider to the new provider and clear PendingMigrationProviderId.

If the migrating user was previously a local account (Wayd→OIDC), the PasswordHash is cleared after the transaction commits — the hash is already unreachable at login (the Wayd identity is deactivated), but removing it makes the intent explicit.

Re-staging an already-pending migration silently overwrites the previous target. Cancellation is idempotent. The action is available for any user whose current provider differs from the target; it is hidden when only one OIDC provider is configured.

OIDC→local conversion​

An admin can convert an OIDC user to a local (password-based) account immediately — no deferred rebind, the change takes effect at once. This is the reverse of Wayd→OIDC and requires the admin to set a temporary password. The user is forced to change it on next login.

The operation runs entirely inside one transaction:

  1. Deactivate all active identity rows (UnlinkReason = ProviderRelinked).
  2. Insert a new active Wayd identity row.
  3. Reset the password hash to the supplied temporary password.
  4. Update LoginProvider to "Wayd" and set MustChangePassword = true.

Password policy is validated before the transaction opens — a bad password returns a clean failure without touching the database.

Security Headers​

Operators: these are on by default and need no setup. You only need this section if Wayd must be embedded in an iframe, or your edge already sets these headers — see when to change them. After deploying, verify they survive your ingress with the check in First-Run Setup → Step 4.

Every API response carries a fixed set of security headers, applied by middleware early in the pipeline (UseSecurityHeaders, before routing so it covers the Scalar API reference and the Hangfire dashboard as well as controllers).

Most of these are not configurable, deliberately. X-Content-Type-Options, X-Frame-Options and the CSP each have exactly one correct value for this application, so they are constants in Wayd.Infrastructure/SecurityHeaders/SecurityHeaderDefaults.cs. A setting for them would only be a way to get them wrong.

HeaderValueConfigurable
X-Content-Type-OptionsnosniffNo
X-Frame-OptionsDENYNo
Content-Security-Policyframe-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'No
Referrer-Policystrict-origin-when-cross-originYes
Permissions-Policygeolocation=(), camera=(), microphone=()Yes
Strict-Transport-Securitynot sent by default — see belowYes

Configure the rest in Wayd.Web/src/Wayd.Web.Api/Configurations/securityheaders.json:

{
"SecurityHeaderSettings": {
"Enable": true,
"ReferrerPolicy": "strict-origin-when-cross-origin",
"PermissionsPolicy": "geolocation=(), camera=(), microphone=()",
"EnableHsts": false,
"HstsMaxAgeSeconds": 63072000,
"HstsIncludeSubDomains": true
}
}

Enable: false switches the middleware off entirely — for a deployment whose edge (Front Door, App Gateway) owns these headers instead. Omitting ReferrerPolicy or PermissionsPolicy, or setting either to an empty string, falls back to the value in the table above; it does not drop the header.

Every key can also be set as an environment variable using the SecurityHeaderSettings__ prefix (for example SecurityHeaderSettings__EnableHsts=true), which is the usual route for a container deployment.

When you need to change these​

SituationWhat to do
Wayd must be embedded in an iframeThe CSP and X-Frame-Options are hardcoded to block framing. Relaxing this needs a code change to SecurityHeaderDefaults — prefer naming the specific parent origin (frame-ancestors https://portal.example.com) over allowing all.
Your edge already sets these headersSet Enable: false rather than sending them twice — a duplicated X-Frame-Options is ignored by some browsers.
You need HSTSSee HSTS is off by default. Not a simple flag flip in the current Azure setup.
A header is missing in productionYour ingress is stripping it. Fix it at the edge; the app has already sent it.

Framing is blocked deliberately: Wayd exposes destructive controls behind a permission claim, and without these headers an attacker can overlay an invisible iframe so a logged-in user's click lands on one.

The CSP deliberately omits default-src and script-src​

The API host also serves the Scalar API reference and the Hangfire dashboard, both of which rely on inline scripts. A script-src 'self' breaks both, and relaxing it with 'unsafe-inline' would defeat the point. The four directives above are script-agnostic and safe for every response the API emits.

This means the CSP mitigates clickjacking and injected-form/base-tag attacks, but is not a script-injection defence. That job belongs to output sanitization — see sanitize-schema.ts on the client, which strips raw HTML out of user-supplied markdown.

HSTS is off by default​

Strict-Transport-Security is a browser-side latch: once sent, browsers refuse plain HTTP to that host for the full max-age (two years at the default), and it cannot be recalled from the server. Enabling it against a host that is not fully HTTPS-ready locks clients out for that duration.

The middleware also gates it on context.Request.IsHttps, so it is never emitted over a plain-HTTP hop. In the current Azure deployment TLS terminates at Container Apps ingress and the container listens on plain HTTP (ASPNETCORE_URLS=http://+:8080), so IsHttps holds only because Terraform sets ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, which makes it reflect X-Forwarded-Proto (see Rate limits). Without that setting, EnableHsts: true is a no-op.

No component currently sends HSTS

Container Apps ingress terminates TLS and redirects HTTP→HTTPS, but does not add the header, and there is no Front Door or Application Gateway in .iac/. If HSTS is needed, ramp max-age (300 → 86400 → 63072000) rather than starting at two years, and only add preload if you mean it — that list is effectively permanent.

Deprecated headers​

X-XSS-Protection is intentionally not sent. It is deprecated and non-standard, the filter it enables was removed from Chrome and Edge, and OWASP recommends against setting it because it can introduce cross-site leaks in otherwise safe pages. The CSP replaces it. Don't re-add it.

Tests​

Wayd.Web/tests/Wayd.Web.Api.IntegrationTests/Sut/SecurityHeadersTests.cs asserts these headers on a real response from the booted host, against literal header-name strings rather than the production constants. This is deliberate: the middleware previously shipped reading a settings shape the config file did not have, so every value bound null and no header was sent while the config still looked complete. A test asserting on the bound settings object, or reusing the same constants the middleware uses, would have passed the entire time. Keep both properties if you extend the suite.

Imports​

KeyDefaultPurpose
Imports:ResponseWaitSeconds5How long an import submission waits for its run before answering 202 with the run still in flight. 0 answers immediately. The wait is in time rather than rows because a row's cost differs by an order of magnitude between imports.

Row caps, chunk size, retry count, stall grace periods and the retention window are fixed in code, not configuration — see Settings → System → Imports → Limits and rules for the values. The two maintenance jobs (import-stall-recovery, import-retention-sweep) are scheduled on first start and can be re-timed under Settings → System → Background Jobs.

System settings​

Organization-wide settings that administrators change at runtime live in the database, not in configuration files, and are edited under Settings → System. Each is part of a section: a record in code, stored as one JSON row in App.SystemSettings. There is no table per setting and no column per setting.

public sealed record SchedulingSettings : ISettingsSection<SchedulingSettings>
{
public static string Key => "scheduling";

public string DefaultTimeZone { get; init; } = "UTC";
public int DefaultCommitmentGraceDays { get; init; } = 1;
}

The property initializers are the defaults, and a missing value reads as its default. A section never saved has no row; a row written before a property existed does not carry it. Either way the reader gets the code default, so adding a setting is adding a property with a valid default — no migration. A section with no row keeps following its code defaults as they change, and saving the defaults unchanged does not create one. Once saved, the stored values hold.

Section keys and property names are permanent. They are the stored JSON's keys, so renaming either orphans the saved value and silently reverts it to the default. ISettingsSection.SchemaVersion is recorded with the row for the rare change a fallback to defaults cannot absorb; bump it and transform the older rows when reading them. Properties must be value-comparable (no collections), because a save that changes nothing is detected by record equality.

Reading. Depend on ISettings<TSection> and call Get. Modules depend on the section record, never on keys or JSON. Reads are cached in memory for five minutes; a save clears the entry on the instance that made it, so another API instance serves the old values for at most that long.

Writing. Call ISystemSettingsStore.Save with the whole section and an EventActor. It:

  • validates with every IValidator<TSection> registered — give each section a FluentValidation validator, which is where rules that span two of its fields go;
  • does nothing when the values are those already in effect;
  • saves under a row version, so a second save that read the section before the first committed fails rather than overwriting it;
  • raises SystemSettingsSectionValuesChangedEvent, whose Previous and Current carry every value in effect before and after, defaults included. The Activity log records it under the aggregate type SystemSettingsSection, with an id derived from the scope and key (SystemSettingsSection.IdFor<T>()), so a section's history can be read before its row exists.

Scope is always System today. It is there so per-organization or per-tenant overrides can be added without reshaping the table.

Adding a section: add the record next to SchedulingSettings in Wayd.Common.Domain/Settings/, its validator in Wayd.Common.Application/SystemSettings/, a query, command and activities query that go through the store, endpoints on SystemSettingsController guarded by SystemSettings.View/.Update, and a page under Settings → System. SprintMetricsSettings is the smallest example: one value and no cross-module check.

Public URL​

ApiSettings:PublicUrl (ApiSettings__PublicUrl as an environment variable) is the address clients reach the API at, such as https://api.wayd.example.com. The MCP endpoint's OAuth challenge and protected resource metadata are built from it, because an OAuth issuer and the resource a token is bound to must be one fixed URL, whatever address or scheme a request arrived with.

Unset, the API reads it from each request. That is right for local development, Aspire and docker compose, where the API serves HTTPS itself, and wrong behind a proxy that terminates TLS: the API sees plain HTTP, so every URL reads http:// and MCP clients reject them. A non-Development host logs a warning at startup while it is unset.

  • Azure: .iac/wayd/container-apps.tf sets it from the API container app's name pattern and the environment's default domain. Change it there if the API gets a custom domain.
  • Self-hosted behind a proxy: set it to the origin users reach the API at. The API needs a host of its own, such as api.wayd.example.com, rather than a path under another site's host.

It must be an HTTPS origin — scheme, host and optional port — with no path, query, fragment or credentials (HTTP is accepted only for localhost); anything else stops the host at startup. A trailing slash is ignored, and an internationalized host is published in its ASCII form. A path is refused because OAuth clients look up the well-known documents at the host's root (RFC 8414, RFC 9728), where a proxy mapping a path prefix onto the API would not send them.

DeploymentValue
Azure, default domainhttps://wayd-api-prod.<environment default domain> (set by Terraform)
Azure, custom domainhttps://api.wayd.example.com
Self-hosted behind a proxyhttps://wayd-api.example.com
Local, Aspire, docker composeunset

Web client URL​

ApiSettings:WebClientUrl (ApiSettings__WebClientUrl) is the address people reach the web app at, such as https://wayd.example.com. The OAuth authorization endpoint sends the user's browser there to consent, and it cannot be read from a request because the web app is served from another origin. It follows the same rules as the public URL: an HTTPS origin with no path (HTTP only for localhost), or the host fails at startup.

Unset, MCP clients cannot sign in with OAuth: the authorization endpoint answers server_error, and the host logs a warning at startup. Personal access tokens are unaffected.

DeploymentValue
Azureclient_url when set, otherwise the client container app's default hostname (set by Terraform)
Aspire, localhttp://localhost:3000 (appsettings.Development.json)
docker composehttp://localhost:5002

Rate limits​

RateLimitSettings holds the limits of the API's rate-limited endpoints, each a fixed window per caller address. Changes take effect without a restart, from each caller's next window; a limit with a PermitLimit or WindowSeconds of zero or less stops the host at startup. A refused request gets 429 with Retry-After.

SettingDefaultEndpoint
RateLimitSettings:OAuthAuthorize30 per 60 sGET /oauth/authorize
RateLimitSettings:OAuthToken60 per 60 sPOST /oauth/token

Each has PermitLimit and WindowSeconds. Limits are held in memory, so every replica counts separately.

The caller address is the proxy's unless forwarded headers are on. Behind a proxy every request appears to come from it, so a per-address limit becomes one limit shared by everyone, and the sessions list records the proxy's address. ASPNETCORE_FORWARDEDHEADERS_ENABLED=true makes ASP.NET Core trust X-Forwarded-For and X-Forwarded-Proto from any sender, which is safe only where the proxy is the sole way in, as Container Apps ingress is; Terraform sets it in Azure. Self-hosted behind a proxy that can be bypassed, configure ForwardedHeadersOptions with the proxy's address instead.

CORS​

CorsSettings:WebClient (Configurations/cors.json) is the semicolon-separated list of origins the web client may call the API from, with credentials. The default lists https://localhost:5001, http://localhost:3000 (Aspire, npm run dev) and http://localhost:5002 (docker compose). An origin missing from it fails every request from the browser, including the login page's call to /api/auth/providers — see Local Development. Terraform sets it in Azure from the client's hostname and client_url.

Background jobs​

Hangfire is configured under HangfireSettings in Configurations/hangfire.json:

SettingDefaultWhat it does
Route/jobsWhere the API serves the Hangfire dashboard.
Storage:StorageProvidermssqlThe only provider wired up.
Storage:ConnectionString—Hangfire's own connection string, separate from DatabaseSettings:ConnectionString; both must be set.
Storage:Options—Hangfire's SqlServerStorageOptions.
Server—Hangfire's BackgroundJobServerOptions (WorkerCount 5, queues default and notdefault).
Dashboard—Hangfire's DashboardOptions (DashboardTitle, StatsPollingInterval, AppPath).
Credentials:User / Credentials:Passwordadmin / Test123$Basic-auth login for the dashboard.
Change the dashboard credentials

The checked-in Credentials are public. Override HangfireSettings__Credentials__User and HangfireSettings__Credentials__Password in any deployment reachable by anyone but you. Jobs are scheduled and re-timed in the app under Settings → System → Background Jobs, so the dashboard is a diagnostic surface only.

OpenAPI and the API reference​

SwaggerSettings (Configurations/openapi.json) controls the published OpenAPI document. Enable (on by default) maps the document and the Scalar API reference at /scalar; off, neither is served. Title, Version, Description, ContactName, ContactEmail, ContactUrl, License, LicenseName and LicenseUrl fill the document's info block. The Debug build reads this document to regenerate the TypeScript client — see Wayd.Web.Api/AGENTS.md.

Request logging​

MiddlewareSettings:EnableHttpsLogging (Configurations/middleware.json, default true) turns on the request-logging middleware, which logs each request at a level set by its status code: 401 and 403 as warnings, 404 and 422 as information, any other status above 400 or an exception as an error, and the rest as information. Serilog itself is configured in Configurations/logger.json (console sink by default); docker compose adds the Seq sink through Serilog__* environment variables.

Real-time (SignalR)​

The planning poker and story map hubs (/hubs/planning-poker, /hubs/story-maps) run on the API's own SignalR by default. Setting Azure:SignalR:ConnectionString (Azure__SignalR__ConnectionString) moves them to Azure SignalR Service, as Terraform does in Azure. The service token it issues leaves out the permission claims, which would exceed its 4 KB cap; hub authorization reads permissions from the database instead.

Wolverine handler codegen​

Wolverine:CodegenMode chooses how Wolverine loads message handlers. Static, the default, loads the handler tree generated ahead of time; Auto compiles handlers at runtime, and appsettings.Development.json sets it so a local run needs no codegen step. A Production host that resolves to anything but Static refuses to start. See Wayd.Web.Api/AGENTS.md.

Environment Variables​

VariablePurpose
ApiSettings__PublicUrlThe API's public URL — see Public URL
ApiSettings__WebClientUrlThe web app's URL — see Web client URL
ASPNETCORE_FORWARDEDHEADERS_ENABLEDTrust a proxy's forwarded headers — see Rate limits
CorsSettings__WebClientAllowed web client origins — see CORS
Azure__SignalR__ConnectionStringAzure SignalR Service — see Real-time (SignalR)
OTEL_EXPORTER_OTLP_ENDPOINTOpenTelemetry collector endpoint; also turns on the Serilog OTLP sink
OTEL_SERVICE_NAMEservice.name on logs sent over OTLP (default wayd-api)
APPLICATIONINSIGHTS_CONNECTION_STRINGAzure Application Insights
ASPNETCORE_ENVIRONMENTRuntime environment (Development, Production)
WAYD_STATIC_HANDLERSBuild-time: true compiles the generated Wolverine handler tree into a local build (CI and the Dockerfile set it)
WAYD_SKIP_DB_INITtrue skips startup database work (migrations and seeding, the first-run check, recurring-job registration, the Hangfire server and dashboard); the Debug build sets it while it boots the API to read the OpenAPI document
WAYD_NO_LAUNCH_BROWSERAppHost only: true or 1 stops it opening the Aspire dashboard