Configuration
Configuration sources
The API reads its settings from these sources, each overriding the one before (Wayd.Web.Api/Configurations/ConfigureServices.cs):
appsettings.json, thenappsettings.{Environment}.json.- 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}.jsonoverlay (database.Development.json,hangfire.Development.json). - User secrets (
dotnet user-secrets, inWayd.Web/src/Wayd.Web.Api). - 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.
| Setting | Default | What it does |
|---|---|---|
DBProvider | — | mssql. The only provider wired up. |
ConnectionString | — | Where the application's data lives. |
EnableSensitiveDataLogging | false | Includes parameter values in EF Core's command logging. On in Development only — a failed bulk write otherwise logs every parameter of every row. |
CommandTimeoutSeconds | 30 | How 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.
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 field | Purpose |
|---|---|
Name | Stable key written into UserIdentity.Provider. Immutable post-create. Reserved value "Wayd" (local accounts). |
DisplayName | Human-readable label rendered on the login page. |
ProviderType | MicrosoftEntraId (multi-tenant via /common/, tenant allowlist) or GenericOidc (single-issuer, no tenant check). Immutable post-create. |
Authority | OIDC issuer URL. Must be https. Wayd appends /.well-known/openid-configuration for discovery. |
ClientId | Public OAuth client ID. Surfaced to the frontend so the OIDC client can be constructed. |
Audience | Pinned aud claim on incoming tokens. See Finding your Audience below. |
Scopes | OAuth scopes the frontend requests when initiating sign-in. |
AllowedTenantIds | Entra 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. |
ClockSkewSeconds | Tolerance for expiry/not-before checks. Defaults to 60; range [0, 600]. |
IsEnabled | Disabled providers are hidden from the login page; exchange attempts against them return 401. |
AllowAutoRegistration | Master 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. |
RequireEmployeeRecord | When 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. |
DefaultRoleId | Role 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:
| Field | Value |
|---|---|
Authority | https://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). |
ClientId | The client (SPA) app registration's client ID, presented to Entra when initiating sign-in. |
Scopes | The 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}. |
Audience | The API app registration's client ID, pinned as the expected aud claim. See Finding your Audience below. |
AllowedTenantIds | The 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).
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: 2in the app manifest):audis the bare<ClientId>GUID. This is what Wayd expects. - v1.0 tokens (the default for older registrations, or when
api.requestedAccessTokenVersionisnull/1):audisapi://<ClientId>orapi://<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)"
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.
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 suchA 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 haveProviderTenantId = NULL) coexist with Entra rows that haven't yet had their tenant populated. - At most one active
UserIdentityperApplicationUserat 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, settingUnlinkedAt+ anUnlinkReason. - 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.
AllowAutoRegistrationdefaults tofalse. A freshly configured provider does not auto-provision until an admin opts in — including providers that predate this setting (the migration backfills existing rows tofalse, 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
Employeerecord. That gate is nowRequireEmployeeRecord, defaulting totrue. 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 withNO ACTIONon delete, so a role that's pinned as a provider's default cannot be deleted until the provider is repointed;RoleServicesurfaces 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
AllowedTenantIdshas 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:
- Deactivate all active identity rows (
UnlinkReason = TenantMigration). - Insert a new active row with
(MicrosoftEntraId, token.tid, token.sub). - Clear
PendingMigrationTenantIdandPendingMigrationStagedAt(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:
- Deactivate all active identity rows (
UnlinkReason = ProviderRelinked). - Insert a new active row with
(providerName, providerTenantId, token.sub)—providerTenantIdis the token's tenant for Entra,nullfor GenericOidc. - Update
LoginProviderto the new provider and clearPendingMigrationProviderId.
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:
- Deactivate all active identity rows (
UnlinkReason = ProviderRelinked). - Insert a new active
Waydidentity row. - Reset the password hash to the supplied temporary password.
- Update
LoginProviderto"Wayd"and setMustChangePassword = 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.
| Header | Value | Configurable |
|---|---|---|
X-Content-Type-Options | nosniff | No |
X-Frame-Options | DENY | No |
Content-Security-Policy | frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none' | No |
Referrer-Policy | strict-origin-when-cross-origin | Yes |
Permissions-Policy | geolocation=(), camera=(), microphone=() | Yes |
Strict-Transport-Security | not sent by default — see below | Yes |
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
| Situation | What to do |
|---|---|
| Wayd must be embedded in an iframe | The 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 headers | Set Enable: false rather than sending them twice — a duplicated X-Frame-Options is ignored by some browsers. |
| You need HSTS | See HSTS is off by default. Not a simple flag flip in the current Azure setup. |
| A header is missing in production | Your 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.
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
| Key | Default | Purpose |
|---|---|---|
Imports:ResponseWaitSeconds | 5 | How 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, whosePreviousandCurrentcarry every value in effect before and after, defaults included. The Activity log records it under the aggregate typeSystemSettingsSection, 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.tfsets 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.
| Deployment | Value |
|---|---|
| Azure, default domain | https://wayd-api-prod.<environment default domain> (set by Terraform) |
| Azure, custom domain | https://api.wayd.example.com |
| Self-hosted behind a proxy | https://wayd-api.example.com |
Local, Aspire, docker compose | unset |
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.
| Deployment | Value |
|---|---|
| Azure | client_url when set, otherwise the client container app's default hostname (set by Terraform) |
| Aspire, local | http://localhost:3000 (appsettings.Development.json) |
docker compose | http://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.
| Setting | Default | Endpoint |
|---|---|---|
RateLimitSettings:OAuthAuthorize | 30 per 60 s | GET /oauth/authorize |
RateLimitSettings:OAuthToken | 60 per 60 s | POST /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:
| Setting | Default | What it does |
|---|---|---|
Route | /jobs | Where the API serves the Hangfire dashboard. |
Storage:StorageProvider | mssql | The 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:Password | admin / Test123$ | Basic-auth login for the dashboard. |
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
| Variable | Purpose |
|---|---|
ApiSettings__PublicUrl | The API's public URL — see Public URL |
ApiSettings__WebClientUrl | The web app's URL — see Web client URL |
ASPNETCORE_FORWARDEDHEADERS_ENABLED | Trust a proxy's forwarded headers — see Rate limits |
CorsSettings__WebClient | Allowed web client origins — see CORS |
Azure__SignalR__ConnectionString | Azure SignalR Service — see Real-time (SignalR) |
OTEL_EXPORTER_OTLP_ENDPOINT | OpenTelemetry collector endpoint; also turns on the Serilog OTLP sink |
OTEL_SERVICE_NAME | service.name on logs sent over OTLP (default wayd-api) |
APPLICATIONINSIGHTS_CONNECTION_STRING | Azure Application Insights |
ASPNETCORE_ENVIRONMENT | Runtime environment (Development, Production) |
WAYD_STATIC_HANDLERS | Build-time: true compiles the generated Wolverine handler tree into a local build (CI and the Dockerfile set it) |
WAYD_SKIP_DB_INIT | true 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_BROWSER | AppHost only: true or 1 stops it opening the Aspire dashboard |