# Your app's users: settings and admin

Sign-in settings for a project's app, and admin actions on its users. How sign-in works inside the app is in [Sign-in for your app's users](../apps/auth.md). Everything here is also on **Project → Users** and **Auth settings** in the dashboard.

All paths are under `/v1/projects/:projectId/auth`. Errors use the usual shape: `{ "error": { "code", "message", "docsUrl", "details"? } }`. Every change is in the audit log.

## Settings

| Method and path | Permission | What it does |
| --- | --- | --- |
| `GET /settings` | `projects:read` | The settings. Secrets show only as `clientSecretSet: true` or `secretKeySet: true`. |
| `PUT /settings` | `projects:write` | Changes the fields you send; the rest stay. Reaches the app on its next deploy. |

```json
{
  "appName": "Ada's shop",
  "methods": { "emailPassword": true, "magicLink": true, "emailOtp": false, "passkeys": true },
  "emailVerification": "required",
  "providers": {
    "google": { "clientId": "123.apps.googleusercontent.com", "clientSecret": "…" },
    "microsoft": { "clientId": "…", "clientSecret": "…", "tenantId": "common" },
    "github": null
  },
  "turnstile": { "siteKey": "0x4AAA…", "secretKey": "…" },
  "trustedOrigins": ["https://www.example.com"],
  "templates": { "passwordReset": "my-reset-email" },
  "passwordResetPage": "/reset-password",
  "invitationPage": "/accept-invitation"
}
```

- A provider set to `null`, or `turnstile: null`, turns it off.
- To change a client ID without retyping the secret, leave `clientSecret` out. A provider added for the first time needs its secret: without it the answer is `400 validation_failed`.
- Secrets are encrypted with the project's key and only ever sent to the app's own Worker. The audit entry lists which fields changed, never their values.
- `appName` appears in auth emails and on passkeys; it's the project's name until you set it.
- Settings belong to the account that owns the project: a token for another account gets `404 not_found` from both routes and changes nothing.

### From the CLI and MCP

The same settings, without writing JSON:

| CLI | MCP | Permission |
| --- | --- | --- |
| `flatstack auth settings` | `get_auth_settings` | `projects:read` |
| `flatstack auth set --email-otp off --email-verification required …` | `set_auth_settings` | `projects:write` |
| `flatstack auth providers set google --client-id …`, `flatstack auth providers remove google --yes` | `set_auth_settings` with `providers` | `projects:write` |
| `flatstack auth turnstile set --site-key …`, `flatstack auth turnstile remove --yes` | `set_auth_settings` with `turnstile` | `projects:write` |

`auth providers set` and `auth turnstile set` ask for the secret without showing it, or read it from stdin, so it stays out of your shell history; `--keep-secret` changes the rest and keeps the stored one. `set_auth_settings` takes the same body as `PUT /settings`, plus `projectId`. Neither the CLI nor any MCP tool ever prints or returns a secret. See [the CLI](../apps/cli.md#secrets) and [MCP](../apps/mcp.md#tools).

## Users

Each of these takes `?environment=` (`production` unless given). Users are per environment: a preview has its own.

| Method and path | Permission | What it does |
| --- | --- | --- |
| `GET /users?q=&limit=&offset=` | `data:read` | Newest first. `q` matches email or name (any part, any case) or an exact user ID. `limit` is 50 by default, at most 200. Returns `{ users, total, limit, offset }`. |
| `GET /users/:userId` | `data:read` | One user. |
| `PATCH /users/:userId` | `data:write` | `{ "role": "editor" }`. Up to 32 lowercase letters, numbers, `-` and `_`. |
| `POST /users/:userId/ban` | `data:write` | `{ "reason"?, "expiresAt"? }`. Signs the user out everywhere; they can't sign in until unbanned or `expiresAt`. |
| `POST /users/:userId/unban` | `data:write` | |
| `POST /users/:userId/unlock` | `data:write` | Clears a lockout after 10 failed passwords. |
| `POST /users/:userId/sign-out` | `data:write` | Ends every session. Returns `{ sessions }`, how many. |
| `POST /users/:userId/password-reset` | `data:write` | Emails the user a reset link from the project's sender. Returns `{ sent: true, to }`. |
| `POST /users/:userId/impersonate` | `data:write`, signed-in owner or admin only | Returns `{ url, expiresAt, user }`. |

A user looks like:

```json
{
  "id": "Xk3…",
  "name": "Ada",
  "email": "ada@example.com",
  "emailVerified": true,
  "role": "user",
  "banned": false,
  "banReason": null,
  "banExpires": null,
  "lockedUntil": null,
  "lastActiveDay": "2026-11-03",
  "createdAt": "2026-10-29T09:52:17.474Z"
}
```

### Impersonation

`url` opens the app signed in as the user, on the app's own address. It works once, within 5 minutes; the session it starts lasts an hour and shows a banner on every page saying who you're signed in as, with **Stop impersonating**. Deploy tokens and personal access tokens get `403 forbidden`: impersonation needs a person, signed in to the dashboard. A banned user can't be impersonated (`409 conflict`). The audit entry is `end_user.impersonated`, with the user's ID.

### Audit actions

`auth.settings_updated`, `end_user.role_changed`, `end_user.banned`, `end_user.unbanned`, `end_user.unlocked`, `end_user.signed_out`, `end_user.password_reset_sent`, `end_user.impersonated`.