
# Update the environment settings

`PATCH /v1/environment-settings` · permission `environment-settings:write`

Updates the environment settings with a JSON merge patch (`application/merge-patch+json`, RFC 7396). `If-Match` is required: missing gives `428`, a stale version gives `412`. If the settings were never written the version is `"0"` and the first write creates the row. A patch that changes nothing writes nothing and keeps the version.

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `If-Match` | header | yes | The `ETag` of the settings as last read (`"0"` if never written). Missing: `428`; stale: `412`. |
| `locales.enabled` | string[] | no | Enabled language codes (registered languages), 1-64 entries, no duplicates. Cannot be `null`. |
| `locales.default` | string | no | Default language; must be one of `locales.enabled`. Cannot be `null`. |
| `default_zoneinfo` | string | no | IANA time zone name (default `UTC`). Cannot be `null`. |
| `session.idle_timeout_s` | integer | no | Idle timeout in seconds, 60-8640000 (default 259200); not greater than `session.absolute_timeout_s`. Cannot be `null`. |
| `session.absolute_timeout_s` | integer | no | Absolute lifetime in seconds, 60-31536000 (default 2592000). Cannot be `null`. |
| `session.max_concurrent` | integer | no | Concurrent sessions per user, 0-500; `0` means the system limit (default). Cannot be `null`. |
| `session.concurrent_mode` | string | no | `evict_oldest` (default) or `deny_new`. Cannot be `null`. |
| `session.anomaly_action` | string | no | `enforce` (default) or `log`. Cannot be `null`. |
| `signup.mode` | string | no | `open` (default), `invite_only` or `closed`. Cannot be `null`. |
| `signup.email_domain_mode` | string | no | `off` (default), `allowlist` or `denylist`. Cannot be `null`. |
| `signup.email_domains` | string[] \| null | no | At most 1000 domains; replaces the whole list (RFC 7396 does not merge arrays). `null` or `[]` clears it. Entries are normalized (lowercase, IDNA A-label), duplicates are dropped and the list is stored sorted. Kept while the mode is `off`. |
| `security_notifications.enabled` | boolean | no | Master switch (default `true`). Cannot be `null`. |
| `security_notifications.events` | string[] \| null | no | Subset of the 12 security events (`password_changed`, `password_reset`, `email_changed`, `phone_changed`, `authenticator_added`, `authenticator_removed`, `recovery_code_used`, `recovery_codes_regenerated`, `account_locked`, `breached_password_detected`, `new_device_sign_in`, `account_recovery_started`); replaces the whole list and is returned in canonical order. `null` or `[]` clears it (no event). |
| `account_recovery.mode` | string | no | `self_service_waiting` (default), `admin_only` or `disabled`. Cannot be `null`. |
| `account_recovery.waiting_hours` | integer | no | 24-168 hours (default 72); stored in every mode. Cannot be `null`. |
| `account_recovery.support_url` | string | no | Empty or an `https://` URL of at most 2048 bytes, without user info or fragment. Shown when recovery is `disabled`. Cannot be `null`. |
| `mfa.email_second_factor_enabled` | boolean | no | Email second factor on or off (default `false`). Cannot be `null`. |
| `mfa.email_second_factor_acr` | string | no | Assurance the email factor counts as: `1fa` (default) or `mfa`. Cannot be `null`. |
| `mfa.totp_issuer_label` | string | no | TOTP issuer label, at most 64 characters; empty means the environment's display name. No control characters, `:` or leading/trailing spaces. Cannot be `null`. |
| `mfa.recovery_codes_enabled` | boolean | no | Recovery codes on or off (default `true`). Cannot be `null`. |
| `mfa.recovery_code_count` | integer | no | Number of recovery codes, 8-16 (default 10). Cannot be `null`. |
| `mfa.required` | boolean | no | Require a second factor at every sign-in in the environment (default `false`). Combined with OR with the application, organization and request requirements; an existing SSO session below it is sent to step up (`interaction_required` with `prompt=none`). Cannot be `null`. |
| `mfa.min_acr` | string | no | Lowest assurance every sign-in in the environment must reach: `1fa` (default, no effect), `mfa` or `phr`. The highest of the environment, application, organization and request thresholds applies, to new sign-ins and to SSO session reuse. Cannot be `null`. |
| `mfa.enrollment_grace_days` | integer | no | Days a user without a second factor may keep signing in without enrolling, counted from the first time enrollment is required of them, 0-14 (default `0`, no grace). The grace postpones only what the environment and organization axes require; an application's `min_acr` and the requested `acr_values` are never postponed. Out of range returns `422 validation_failed` with `out_of_range`. Shortening it takes effect at once. Cannot be `null`. |
| `frontchannel_logout_enabled` | boolean | no | Front-Channel Logout on or off (default `false`); turning it off keeps the registered URIs. Cannot be `null`. |
| `allowed_first_factors` | string[] | no | Allowed first factors: at least one of `password`, `passkey`, `email_otp`, `magic_link`, `sms_otp`, `social`, `enterprise`, no duplicates. Replaces the whole list and is returned in this order. Default `password`, `passkey`, `social`, `enterprise`; `email_otp`, `magic_link` and `sms_otp` are on only when added explicitly. An empty list returns `422 required` (an environment nobody could sign in to). Cannot be `null`. |

## Behavior

- Groups merge recursively: send only the fields you change inside `locales`, `session`, `signup`, `security_notifications`, `account_recovery` and `mfa`. A group itself cannot be `null`.
- Shortening a session lifetime, lowering the concurrent session limit, narrowing sign-up or account recovery, lowering the email second factor from `mfa` to `1fa`, and removing a factor from `allowed_first_factors` apply to new sessions and sign-ins immediately; existing sessions keep their own lifetime.
- Validation errors carry the JSON pointer of the field, for example `/session/idle_timeout_s` with code `out_of_range`. An error in a list entry points at the entry: `/allowed_first_factors/1` with `unknown_value` (unknown value) or `duplicate` (repeated value).
- `allowed_first_factors` is intersected with application and organization policies: they can only narrow this set.
- If the new set leaves no method in common with an application that has its own `allowed_first_factors`, the request returns `422 policy_conflict`; change the application's set first.
- The audit record and the change event carry the names of the changed fields, never their values. Attack protection is a separate resource and is not part of this one.

## Response

`200 OK` with the updated settings and the new `ETag`.

```json
{
  "locales": {"enabled": ["en", "tr"], "default": "tr"},
  "default_zoneinfo": "Europe/Istanbul",
  "session": {"idle_timeout_s": 3600, "absolute_timeout_s": 2592000, "max_concurrent": 5, "concurrent_mode": "evict_oldest", "anomaly_action": "enforce"},
  "signup": {"mode": "invite_only", "email_domain_mode": "allowlist", "email_domains": ["example.com"]},
  "security_notifications": {"enabled": true, "events": ["password_changed", "password_reset"]},
  "account_recovery": {"mode": "self_service_waiting", "waiting_hours": 72, "support_url": ""},
  "mfa": {"email_second_factor_enabled": false, "email_second_factor_acr": "1fa", "totp_issuer_label": "Acme", "recovery_codes_enabled": true, "recovery_code_count": 10, "required": false, "min_acr": "1fa", "enrollment_grace_days": 0},
  "frontchannel_logout_enabled": false,
  "allowed_first_factors": ["password", "passkey", "magic_link", "enterprise"],
  "updated_at": "2026-10-05T09:30:00.123456Z"
}
```

## Errors

| HTTP | code | When |
|---|---|---|
| 400 | `unknown_field` | The patch contains an unknown or read-only field, or is not a JSON object (`malformed_request`). |
| 412 | `precondition_failed` | `If-Match` does not match the current version. |
| 415 | `unsupported_media_type` | The content type is not `application/merge-patch+json`. |
| 422 | `validation_failed` | A value is invalid (the pointer names the field), or a field that cannot be `null` was set to `null` (`code: invalid`). |
| 422 | `policy_conflict` | The new `allowed_first_factors` leaves no method in common with an application that has its own set. |
| 428 | `precondition_required` | The `If-Match` header is missing. |
| 401 | `unauthenticated` | The credential is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The credential lacks the required permission. |
| 429 | `rate_limited` | The rate limit was exceeded; retry after the `Retry-After` period. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable; retry after the `Retry-After` period. |

## Example

Turn on email magic links and turn off passwords:

```bash
curl -X PATCH https://api.eu.keenauth.com/v1/environment-settings \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/merge-patch+json" \
  -H 'If-Match: "0"' \
  -d '{"allowed_first_factors":["passkey","magic_link","social","enterprise"]}'
```

Session and sign-up settings:

```bash
curl -X PATCH https://api.eu.keenauth.com/v1/environment-settings \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/merge-patch+json" \
  -H 'If-Match: "0"' \
  -d '{"session":{"idle_timeout_s":3600},"signup":{"mode":"invite_only","email_domain_mode":"allowlist","email_domains":["example.com"]}}'
```
