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

# Security doctor

> theauth doctor --security lints a config file for weak secrets, loose cookies, disabled lockout and open handlers, with stable finding ids and a CI exit code.

`theauth doctor --security` reads a theauth config file and reports unsafe settings. It does not open a database or call a server, so it runs in CI before deploy. For the setup and live server checks, see [CLI tools](/cli-tools).

```sh theme={"dark"}
theauth doctor --security
theauth doctor --security --config ./config/theauth.json --json --fail-on high
```

## Options

| Option | Meaning |
| - | - |
| `--config <file>` | JSON or ES module config. Default: `theauth.config.mjs`, `.js` or `.json` in `--cwd`. |
| `--cwd <dir>` | Directory to look in. |
| `--json` | Print `{ ok, failOn, findings }`. |
| `--fail-on <level>` | Lowest severity that exits 1: `high` (default), `medium` or `low`. |

Exit codes: 0 no finding at or above the threshold, 1 at least one, 2 usage error or a config that cannot be loaded.

## Output

```json theme={"dark"}
{
  "ok": false,
  "failOn": "high",
  "findings": [
    {
      "id": "sec.lockout.off",
      "severity": "high",
      "message": "Login lockout is turned off, so one account can be guessed without limit",
      "fix": "Add rateLimit() to plugins, or confirm the handler or a gateway in front enforces limits.",
      "docs": "/security-doctor#sec-lockout-off"
    }
  ]
}
```

Finding ids never change once released, so you can suppress or alert on them. Findings are sorted high first.

## Findings

The linter reads the real `createTheAuth` config (`TheAuthConfig`). Settings that live outside that object cannot be read, so they are not linted: the `createTheAuthHandler` options (`cors`, `rateLimit`, `trustedOrigins`), the cookie flags of the session cookie, the options a plugin factory such as `rateLimit()` closes over, the `resolveUser` dependency of a handler, a stateless session manager, and the audit anchors you store outside the database.

<a id="sec-session-secret-weak" />

### sec.session.secret\_weak (high)

`secret`, `auth.session.secret`, `mcp.signingSecret` (or `THEAUTH_SECRET`) is under 32 characters, repeats few distinct characters, or contains a word such as `secret`, `password`, `example` or `changeme`. Generate one with `theauth secret`.

<a id="sec-cookie-secure-off" />

### sec.cookie.secure\_off (high)

`redirects.cookie.secure` is `false` while `baseUrl` is not localhost. The redirect chain cookie is the only cookie whose flags `TheAuthConfig` carries. The session cookie is `Secure` by default on every runtime (it no longer depends on `NODE_ENV`), and its flags are not config options.

<a id="sec-cookie-samesite-none" />

### sec.cookie.samesite\_none (high)

`redirects.cookie.sameSite` is `none`. Use `lax` or `strict` unless you embed the app cross-site on purpose.

<a id="sec-cookie-host-prefix-missing" />

### sec.cookie.host\_prefix\_missing (low)

`auth.session` is set, `baseUrl` is not localhost, and `auth.session.cookieName` (default `theauth_session`) does not start with `__Host-`. The prefix stops a sibling subdomain from overwriting the cookie. Fires on the default name.

<a id="sec-password-pbkdf2-low" />

### sec.password.pbkdf2\_low (high)

`username.pbkdf2Iterations`, `passwordReset.pbkdf2Iterations` or `THEAUTH_PBKDF2_ITERATIONS` resolves below 600000 (the core default) for a flow that is configured.

<a id="sec-rate-limit-off" />

### sec.rate\_limit.off (medium)

`username`, `passwordReset`, `magicLink`, `emailOtp` or `phone` is configured and `plugins` has no `rateLimit()` plugin (id `theauth-rate-limit`). A limit you disable inside `rateLimit()` is not visible to the linter, and neither are the `createTheAuthHandler` limits, so this checks that the plugin is registered. Ignore it when the handler or a gateway enforces limits.

<a id="sec-lockout-off" />

### sec.lockout.off (high)

`username.lockout` is `false`, or `THEAUTH_LOGIN_LOCKOUT_DISABLED` is `1` or `true`, while `username` is configured. One account can then be guessed without limit.

<a id="sec-hosts-wildcard" />

### sec.hosts.wildcard (high)

`allowedHosts` contains a bare `*` or `**` (optionally with a scheme), so any `Host` header is accepted when building callback and magic link URLs. Use exact hosts or a suffix pattern such as `*.example.com`. This replaces the earlier `sec.cors.wildcard`, which read options that are not part of the config.

<a id="sec-redirect-allowlist-empty" />

### sec.redirect.allowlist\_empty (medium)

`redirects` is set but `redirects.allowedOrigins` is empty or missing, so only same-origin return URLs work.

<a id="sec-mcp-revocation-offline" />

### sec.mcp.revocation\_offline (medium)

`mcp.enabled` with `mcp.revocation.mode` `offline` (or `THEAUTH_MCP_REVOCATION_MODE=offline`). Revoked tokens then stay valid until they expire. Use `live` or `cached`. When neither the config nor the env sets a mode, core picks `live` if the module has a database, so the linter stays quiet.

<a id="sec-org-no-auth" />

### sec.org.no\_auth (high)

`org.trustedServerUse` is `true`. The org `handleRequest` then acts on ids in the request without resolving a caller. Pass a `resolveUser` dependency and remove the flag, unless your own code authorizes every call first.

<a id="sec-api-keys-no-auth" />

### sec.api\_keys.no\_auth (high)

`apiKeys.trustedServerUse` is `true`. Same risk and fix as the org handler.

<a id="sec-api-keys-no-scope-resolver" />

### sec.api\_keys.no\_scope\_resolver (low)

`apiKeys` is set without `grantablePermissions` and there is no `org` module. Self service creation then fails closed and can only mint keys with an empty permission list. With the org module present, core defaults the cap to the creator's org roles (see below), so the linter stays quiet. A resolver is a function, so a JSON config can never carry one: lint the `.mjs` config for this rule.

<a id="sec-device-path-matching-off" />

### sec.device.path\_matching\_off (medium)

`devices` is set, `resourceMatching` is not `path`, and a key of `permissions` contains `/` plus a `*`, `**`, `+` or `#` segment. In the default mode `*` counts only as a whole `:` segment, so `mqtt:fleet/*/temp` is plain text and never matches a real topic. Set `resourceMatching: "path"`.

<a id="sec-device-mid-wildcard" />

### sec.device.mid\_wildcard (medium)

`devices` is set and a `permissions` resource has a wildcard before its last segment. In the default mode a mid `*` or `**` grants more than it reads. In every mode `#` must be the last level of a topic filter (MQTT 5, section 4.7.1.2).

<a id="sec-device-bundle-ttl-long" />

### sec.device.bundle\_ttl\_long (medium)

`devices.bundleTtlSeconds` is above 3600. An offline policy bundle cannot be revoked once issued, so a revoked device keeps its permissions until the bundle expires.

<a id="sec-device-no-attestation" />

### sec.device.no\_attestation (medium)

`devices` is set without `attestation.verify`. Enrollment tokens can then be issued, and whoever holds one can register any hardware. The verifier is a function, so a JSON config always fires this rule.

<a id="sec-approval-no-step-up" />

### sec.approval.no\_step\_up (medium)

`approval` is set with no `requireAcr`, `maxAuthAge` or `stepUp`. A `requireApproval` permission can then be approved from any signed in session, with no proof of a strong or recent login. A rule passed per request to `approval.request()` is not visible to the linter.

<a id="sec-agent-dpop-not-required" />

### sec.agent.dpop\_not\_required (medium)

`agents.enabled` with `agents.confirmation.required` off, while a `permissions` catalog action or an `agents.defaultPermissions` entry is a write action (`write`, `create`, `update`, `delete`, `admin`, `manage`, `publish`, `execute`, `send` or `*`). A leaked token then works as a bearer token. Require a bound token (RFC 9449 DPoP or RFC 8705 mutual TLS).

## API key scope default

When `apiKeys` has no `grantablePermissions` and the `org` module is on, a self service key can carry a permission only if `org.hasPermission` allows it for the creator in at least one org they belong to. Owners pass for everything, a user with no org membership holds nothing, and a failing lookup denies. The match is exact: a role entry such as `agents:*` does not cover `agents:read`, so use an explicit `grantablePermissions` when your roles use patterns. An explicit `grantablePermissions` still wins, and trusted server code calling `create()` is not capped.

## Dropped checks

* `sec.session.stateless_no_ban_check`: the stateless session manager is created in code, not in `TheAuthConfig`, and its ban check (`db` or `isBanned`) cannot be read from a config file.
* `sec.audit.anchors_missing`: anchors come from `audit.checkpoint()` and are stored outside the config, so no option says whether they exist. See [audit checkpoints](/audit-checkpoints).
* `sec.cors.wildcard`: replaced by `sec.hosts.wildcard`. CORS is an option of `createTheAuthHandler`, not of the config.

## CI

```yaml theme={"dark"}
- run: npx theauth doctor --security --fail-on high
```

The linter reads only the `TheAuthConfig` fields named above. A field it does not know is ignored, and a rule fires only on the unsafe value, so an unset optional field stays quiet unless the table says it fires when missing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.