> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vikat.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign-in security

> Second factor, session timeouts, login lockout and the admin IP allowlist.

Console identity controls: what a sign-in requires, how long it lasts, and what
happens when someone guesses.

Everything here is **off by default**. A gateway upgraded into this release
behaves exactly as it did before — 30-day sessions, no lockout, no MFA. This is
deliberate: a control that switches itself on with thresholds nobody chose locks
operators out of their own gateway during an upgrade, and those are the controls
that get switched off permanently.

Configure it at **Governance → Sign-in Security** in the console, or through
`/api/identity/policy`.

## Multi-factor authentication

Time-based one-time passwords (RFC 6238) from any authenticator app, plus ten
single-use recovery codes.

### Enrolling

1. Open **Sign-in Security** and choose **Set up**.
2. Add the secret to your authenticator app.
3. Enter the code it shows.
4. **Save the recovery codes.** They are displayed once and cannot be retrieved
   afterwards — only replaced.

MFA is not in force until step 3 succeeds. Abandoning enrolment at the QR code
leaves the account exactly as it was, rather than locked behind a secret nobody
holds.

### Signing in

Enter your username and password as usual. If the account has a second factor,
the form asks for a code. A recovery code works in the same field and is
consumed when used.

<Note>
  A wrong code counts toward the lockout counter. Without that, an attacker who
  already has the password gets unlimited guesses at six digits — which falls in
  hours.
</Note>

### Replay

Each code is accepted once. A code observed in its window — shoulder-surfed, or
captured by a phishing page — cannot be presented again. Most TOTP
implementations do not do this, and it is the gap between what TOTP is assumed to
prevent and what it prevents unaided.

### Losing your device

Use a recovery code. If those are gone too, the super-admin can clear the second
factor for an account:

```bash theme={null}
curl -X POST https://gateway.example.com/api/identity/mfa/disable \
  -H 'Content-Type: application/json' \
  -b "token=$SESSION" \
  -d '{"console_user_id":"<id>"}'
```

This is logged. It is also exactly what an attacker with admin access would do
before signing in as someone else, so it is worth alerting on.

### Requiring it

`require_mfa` refuses password sign-in for any console account that has not
enrolled. It does **not** apply to SSO logins — those are authenticated by the
identity provider, which is where MFA belongs in that topology.

The console refuses to save `require_mfa` until the caller has enrolled
themselves. The alternative is a policy that saves cleanly and locks out
everyone including whoever set it.

## Session policy

| Setting                            | Effect                                        | Off when      |
| ---------------------------------- | --------------------------------------------- | ------------- |
| `session_idle_timeout_minutes`     | Ends a session left unused this long          | `0`           |
| `session_absolute_lifetime_hours`  | Caps total session age regardless of activity | `0` → 30 days |
| `max_concurrent_sessions_per_user` | Revokes the oldest sessions past this count   | `0`           |

The idle timeout is the one that matters for an unattended browser on a shared
machine; an absolute lifetime alone does nothing there.

Activity is recorded at most once a minute rather than on every request, so an
idle timeout can overshoot by up to that. A write per request would put the
sessions table on the critical path of every call the gateway serves.

Sessions created before this release have no recorded activity and are given one
full idle window from their creation time, rather than being expired the instant
the timeout is first enabled.

Policy changes reach enforcement within 30 seconds.

## Active sessions

Every signed-in browser, with the account, address, client and last activity.
Revoke anything unfamiliar; revoking your own signs you out.

Sessions carry no token in this view. A session list that included them would
hand every caller the credential for each session it shows, and an administrator
listing everyone's would collect the means to impersonate all of them.

## Login lockout

| Setting                  | Meaning                                                  |
| ------------------------ | -------------------------------------------------------- |
| `lockout_max_failures`   | Consecutive failures tolerated. `0` disables. Minimum 3. |
| `lockout_window_minutes` | How long failures accumulate before being forgotten.     |
| `lockout_minutes`        | How long the account is refused.                         |

A reasonable starting point is 10 failures in 15 minutes, locking for 15 minutes.
Lower thresholds turn an ordinary typo into a lockout, and a lockout that hits
real administrators during an incident is a control that gets disabled.

Locks always expire. An indefinite lock is a denial of service anyone can aim at
a named account by typing a wrong password a few times.

Counters are kept in the governance store, shared across replicas. An in-process
counter is defeated by a restart or a second replica: with N replicas a limit of
5 permits 5N attempts, and nothing shows it happening.

Counters are also kept for usernames that do not exist, so a lockout response
never reveals whether an account is real.

Failed sign-ins are visible on the same page, and an administrator can release an
account by hand.

## Step-up re-authentication

A session cookie proves someone signed in once, possibly a month ago on a
machine they have since walked away from. For most of the console that is the
right trade. For the operations that change **who can get in**, it is not —
those are what an attacker holding a stolen cookie reaches for, and each one
turns a temporary foothold into a permanent one.

Those operations ask for the password again (and a code, if the account has a
second factor). One confirmation opens a five-minute window covering all of
them.

| Operation                          | Why                                                                      |
| ---------------------------------- | ------------------------------------------------------------------------ |
| Change the super-admin credentials | Whoever changes these owns the gateway                                   |
| Create or delete a console user    | A new account outlives the stolen cookie; deleting one removes a witness |
| Change a role's permissions        | Granting a role everything achieves the same as creating an account      |
| Change the sign-in policy          | Turning the controls off is the precondition for everything else         |
| Clear someone else's second factor |                                                                          |
| Release a lockout                  | It is how an in-progress guessing attack gets unblocked                  |

Everything else — providers, virtual keys, teams, guardrails, routing — goes
through untouched. That is deliberate: prompting for a password on routine work
trains people to type it without reading, and that habit is what a convincing
fake login overlay relies on. Those operations are all recorded in the audit
chain, and compromising them does not extend the attacker's access.

SSO accounts have no local password. For them the identity provider's assertion
stands as the proof and the window opens without a prompt. That is weaker than
the password path; the alternative is issuing a local password to an account
whose entire point is not having one.

The confirmation is subject to lockout like any other sign-in. Without that, an
attacker holding a session would have unlimited guesses at the password with no
failed sign-in ever recorded.

## Admin IP allowlist

Restricts the console and admin API to given addresses or CIDR blocks. `/health`
and `/api/version` stay reachable so a load balancer does not take the gateway
out of rotation the moment this is enabled.

<Warning>
  The address compared is the **peer address**, not `X-Forwarded-For`. Trusting a
  client-supplied header would let anyone bypass the allowlist by sending one.

  Behind a load balancer every request appears to come from the balancer, so this
  control does nothing useful in that topology — enforce it at the proxy or the
  security group instead. Saving an allowlist shows you the address the gateway
  actually saw; if that is your balancer rather than your own machine, that is the
  signal.
</Warning>

A malformed entry is rejected rather than silently matching nothing, which would
leave the control off while the console still showed it as on.

## API

| Method   | Path                                | Purpose                                       |
| -------- | ----------------------------------- | --------------------------------------------- |
| `GET`    | `/api/identity/policy`              | Read the policy                               |
| `PUT`    | `/api/identity/policy`              | Update it (super-admin only)                  |
| `GET`    | `/api/identity/sessions`            | Your own sessions                             |
| `GET`    | `/api/identity/sessions/all`        | Every account's (super-admin only)            |
| `DELETE` | `/api/identity/sessions/{id}`       | Revoke one                                    |
| `GET`    | `/api/identity/mfa`                 | Your enrolment status                         |
| `POST`   | `/api/identity/mfa/enroll`          | Start enrolment                               |
| `POST`   | `/api/identity/mfa/confirm`         | Finish it, returns recovery codes             |
| `POST`   | `/api/identity/mfa/disable`         | Turn it off                                   |
| `POST`   | `/api/identity/mfa/recovery-codes`  | Replace the recovery codes                    |
| `GET`    | `/api/identity/lockouts`            | Accounts being guessed at                     |
| `DELETE` | `/api/identity/lockouts/{identity}` | Release one                                   |
| `POST`   | `/api/identity/reauth`              | Confirm a password to open the step-up window |
| `GET`    | `/api/identity/step-up`             | Whether a confirmation is still valid         |

## What this does not cover

* **SAML.** Only OIDC is implemented for SSO.
* **WebAuthn / hardware keys.** TOTP and recovery codes only.
* **Approval / dual-control workflows.** No second person has to approve a
  change; step-up asks the same person again, which is a different control.
* **Per-user policy.** One policy for the whole gateway, not per team or role.
