
# Replace the attack protection settings

`PUT /v1/attack-protection` · permission `attack-protection:write`

Replaces the whole attack protection object (`application/json`). Sections and fields you leave out return to their defaults. `If-Match` is required: missing gives `428`, a stale `ETag` gives `412`. Writing the same object again writes nothing and keeps the `ETag`. To change a few fields, use `PATCH`.

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `If-Match` | header | yes | The `ETag` of the object as last read. Missing: `428`; stale: `412`. |
| body | object | yes | The configured object, at most 32 KiB. Each section is optional; each field inside a section is optional. |

| Field | Type | Range or values | Default | Floor |
|---|---|---|---|---|
| `brute_force.mode` | string | `log`, `enforce` | `enforce` | |
| `brute_force.max_failures` | integer | 5-100 | 20 | at most 100 |
| `brute_force.window_minutes` | integer | 5-60 | 15 | |
| `brute_force.lock_minutes` | integer | 5-60 | 15 | at least 5 |
| `brute_force.notify_user` | boolean | | `true` | |
| `suspicious_ip_throttling.mode` | string | `log`, `enforce` | `enforce` | |
| `suspicious_ip_throttling.login_failures_per_ip_per_day` | integer | 10-10000 | 100 | |
| `suspicious_ip_throttling.signups_per_ip_per_hour` | integer | 1-20 | 20 | at most 20 |
| `suspicious_ip_throttling.allowlist` | string[] | up to 100 CIDR prefixes; IPv4 no wider than /8, IPv6 no wider than /32 | `[]` | |
| `credential_stuffing.mode` | string | `log`, `enforce` | `enforce` | |
| `credential_stuffing.attack_mode_auto` | boolean | | `true` | |
| `credential_stuffing.attack_mode_duration_minutes` | integer | 15-240 | 30 | |
| `bot_protection.mode` | string | `off`, `risk_based`, `always` | `risk_based` | |
| `bot_protection.provider` | string | `altcha` | `altcha` | |
| `breached_password.on_signup` | string | `block` | `block` | `block` |
| `breached_password.on_password_change` | string | `block` | `block` | `block` |
| `breached_password.on_login` | string | `require_reset`, `notify` | `require_reset` | |
| `breached_password.notify_user` | boolean | | `true` | |
| `signup_protection.block_disposable_email` | boolean | | `true` | |
| `signup_protection.block_email_subaddress` | boolean | | `false` | |
| `signup_protection.max_signups_per_device_30d` | integer | 1-50 | 3 | |
| `sms_protection.allowed_countries` | string[] | up to 250 ISO 3166-1 alpha-2 codes | `[]` (plan default) | |
| `sms_protection.daily_cap` | integer \| null | 1-1000000; `null` is the plan value | `null` | |
| `sms_protection.conversion_breaker` | boolean | | `true` | `true` |
| `risk_policy.mode` | string | `log`, `enforce` (`enforce` needs the Startup plan or higher) | `log` | |
| `risk_policy.challenge_threshold` | integer | 3-8 | 4 | |
| `risk_policy.block_threshold` | integer \| null | 6-12, greater than `challenge_threshold`; `null` is off | `null` | |
| `risk_policy.notify_user_on_new_device` | boolean | | `true` | |

A value outside the security floor is refused with `422 below_security_floor` even though it is inside the field's general range: the floor protects every environment and cannot be lowered.

## Behavior

- The change applies to new sign-ins immediately: the cached settings are invalidated in the same transaction.
- The audit record carries the changed field names and the previous and new objects.

## Response

`200 OK` with the object, as in [Get the attack protection settings](get.md), and the new `ETag`.

## Errors

| HTTP | code | When |
|---|---|---|
| 400 | `unknown_field` | A section or field is not part of the object. `errors[]` lists the pointers. |
| 400 | `malformed_request` | The body is not valid JSON, or the `If-Match` header is malformed. |
| 401 | `unauthenticated` | The credential is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The credential lacks `attack-protection:write`. |
| 403 | `plan_feature_unavailable` | `risk_policy.mode` is `enforce` and the plan does not include it; the body carries `feature` and `required_plan`. |
| 412 | `precondition_failed` | The object changed since you read it. Read it again and retry. |
| 413 | `payload_too_large` | The object is larger than 32 KiB. |
| 422 | `validation_failed` | A value has the wrong type or is out of range, or `block_threshold` is not greater than `challenge_threshold`. `errors[]` lists the pointers. |
| 422 | `below_security_floor` | A value is below the security floor. Each `errors[]` entry names the field and its floor in `detail`. |
| 422 | `allowlist_range_too_broad` | An allowlist prefix is wider than /8 (IPv4) or /32 (IPv6). |
| 428 | `precondition_required` | `If-Match` is missing. |
| 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. |

When several kinds of error are present, only the most severe kind is returned, in this order: `unknown_field`, `validation_failed`, `below_security_floor`, `allowlist_range_too_broad`, `plan_feature_unavailable`. The written values are never echoed back.

## Example

```bash
curl -X PUT https://api.eu.keenauth.com/v1/attack-protection \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "3f1c9a0b5d7e2a4c6b8d0e1f2a3b4c5d"' \
  -d '{"brute_force": {"max_failures": 10, "lock_minutes": 30}, "bot_protection": {"mode": "always"}}'
```
