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.
Options
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
Findings
The linter reads the realcreateTheAuth 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.
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.
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.
sec.cookie.samesite_none (high)
redirects.cookie.sameSite is none. Use lax or strict unless you embed the app cross-site on purpose.
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.
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.
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.
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.
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.
sec.redirect.allowlist_empty (medium)
redirects is set but redirects.allowedOrigins is empty or missing, so only same-origin return URLs work.
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.
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.
sec.api_keys.no_auth (high)
apiKeys.trustedServerUse is true. Same risk and fix as the org handler.
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.
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".
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).
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.
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.
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.
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
WhenapiKeys 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 inTheAuthConfig, and its ban check (dborisBanned) cannot be read from a config file.sec.audit.anchors_missing: anchors come fromaudit.checkpoint()and are stored outside the config, so no option says whether they exist. See audit checkpoints.sec.cors.wildcard: replaced bysec.hosts.wildcard. CORS is an option ofcreateTheAuthHandler, not of the config.
CI
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.