Configuration
NanoIDP is configured through two YAML files in the config directory
(./config by default, --config or NANOIDP_CONFIG_DIR to change it).
Everything below can also be managed from the web UI at
http://localhost:8000:
- Dashboard: overview and quick stats
- Users: create, edit, delete users
- OAuth Clients: manage OAuth2 client credentials
- Settings: configure IdP settings (issuer, audience, SAML)
- Keys & Certs: view and regenerate RSA keys
- Claims: configure authority prefix mappings
- Audit Log: view and export authentication events
- Token Tester: generate and inspect tokens
Config schema version
Both files may declare, at the top level, the schema version they follow:
config_version: 1
The version belongs to the configuration directory's contract as a whole,
not to one file: settings.yaml and users.yaml declare the same number
(a mismatch refuses to start), each file is checked independently against
the version the running release supports, and a future bump applies to both files together with one loader
migration. The value must be a literal integer; it is checked before
${VAR} placeholders are expanded, so config_version: ${CONFIG_VERSION:1}
is rejected like any non-integer.
The contract is a single integer, not semver:
- Absent means 1. Existing files need no change;
nanoidp initand the wizard write the key into the files they create, and saves from the web UI or the MCP server preserve it if present and never add it. - Unknown keys are reported. A key nanoidp does not know (a typo such
as
oauth.isuer) is logged as a warning with its path and ignored; the file still loads, unless the directory is validated strictly (see Validating your configuration). Inside a user entry, unknown keys become that user'sattributes, as they always have. - Optional additions never bump it. New optional keys with defaults keep the version; only renames, removals or semantic changes of existing keys do, and such a bump ships with a migration step in the loader.
- A newer version than the running release understands is refused at startup with a message naming the file, the value found and the supported version, as is any value that is not a positive integer.
GET /api/config and the MCP get_settings tool report the effective
config_version, so external tools and agents know which contract to target.
The CHANGELOG carries a "Config schema" section whenever it changes.
The generated schema artifact
The machine-readable form of the contract lives in the repository at
docs/schema/config.v1.json:
one standalone JSON Schema per file, under the keys settings, users and
bootstrap, next to the config_version they describe. Point an editor's
YAML-schema support at the entry matching the file to get completion and
typo detection while writing it.
It is generated, never hand-written - that is the whole point, since a hand-written schema would be one more place the contract could disagree with itself:
nanoidp config-schema # the full document, on stdout
nanoidp config-schema --file users # one file's schema
nanoidp config-schema --write # regenerate docs/schema/config.v1.json
--write targets a path inside the repository and therefore works from a
source checkout only; from an installed package, redirect stdout instead. A
test fails when the committed file no longer matches the models, with the
command to run.
One thing the schema cannot express: a ${VAR} placeholder is a string
until it is expanded, and its expansion is a string too, which nanoidp then
coerces to the field's type. A plain JSON Schema check therefore flags
port: ${PORT:8000} against "type": "integer". Use nanoidp validate-config for that check - it runs the real loader.
Validating your configuration
Unknown keys are reported, not ignored. What "reported" means is a choice:
# settings.yaml
config_validation: warn # default: log the key and its path, keep loading
# config_validation: strict # refuse to start
The value belongs to the configuration directory as a whole: users.yaml
and bootstrap.yaml follow what settings.yaml declares. Wrong types and
refused values are errors in both modes and always have been; strict is
only about the unknown key. settings.yaml and users.yaml are validated
on startup and on every reload; bootstrap.yaml is validated when the
bootstrap surface is loaded, at startup (validate-config checks all
three, so for bootstrap.yaml it reports what would stop the NEXT
startup, not the next reload). A load that fails validates and commits
nothing: the running settings, users, validation mode and hooks stay
exactly as they were, in the same fail-without-commit contract as the
hook registry. The server flag --strict-config turns strict
on for one run, wins over the file, and is never written back:
nanoidp --strict-config # unknown key -> refuse to start
The same validation runs the other way round as well: before any surface
that writes configuration replaces a file, the document it composed is
parsed exactly as a load would parse it, and a document the models refuse
is refused instead of written. So the settings form, /api/runtime
promotion, the MCP save_config tool and nanoidp init cannot leave behind
a settings.yaml the next process would fail to start from. What you get is
an error naming the file and the key, with the file unchanged. The check
expands ${VAR} into a copy, the way a load does, so what is written keeps
its placeholders and what is checked is what the next load will read.
It is a check of the document, not of the running server: whether the configuration can be parsed into settings and users, not whether every service could be built from it. A configuration that parses but whose signing keys cannot be used is still refused at load, where it belongs.
To check a directory without starting anything:
$ nanoidp validate-config --config ./config
validate-config: ./config
warning: config/settings.yaml: unknown key oauth.isuer
0 error(s), 1 warning(s)
$ echo $?
0
$ nanoidp validate-config --config ./config --strict
validate-config: ./config (strict)
warning: config/settings.yaml: unknown key oauth.isuer
0 error(s), 1 warning(s) (strict: warnings fail)
$ echo $?
1
One line per finding, and three exit codes:
| Exit | Meaning |
|---|---|
| 0 | clean, or warnings only without --strict |
| 1 | any error, and any warning under --strict |
| 2 | the directory could not be observed, so no validation was performed |
A directory that declares config_validation: strict is strict either way,
since that is a directory the server would refuse to start on.
Exit 2 is not a verdict on the configuration (#246). It means another
process held the configuration directory long enough that this run could
not read it consistently, so the command answers UNAVAILABLE - validation not performed rather than reporting a directory nobody looked at. Nothing
is retried here: a CI gate that wants to retry can do so on 2 alone, and
tell it apart from 1, which really does say the configuration is wrong. A
file the run could not read is a different thing and stays an ordinary
error, exit 1, attributed to that file.
The command reads the three files through the same loaders the server uses,
and does nothing else: no server, no ConfigManager, no hook dispatched, no
plugin imported. bootstrap.yaml is checked for its shape only, because a
lint step that ran the commands a directory declares would be a remote
execution primitive triggered by looking at it. That is what makes it safe
as a pre-commit or CI step:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: nanoidp-validate-config
name: nanoidp validate-config
entry: nanoidp validate-config --config ./config --strict
language: system
files: ^config/.*\.yaml$
pass_filenames: false
MCP agents have the same check as the read-only validate_config tool,
which returns {valid, findings} for the running config directory.
Users (config/users.yaml)
users:
admin:
password: "admin"
description: "Full administrator" # optional, max 200 chars, display-only
email: "admin@example.org"
identity_class: "INTERNAL"
entitlements:
- "ADMIN_ACCESS"
- "USER_MANAGEMENT"
roles:
- "USER"
- "ADMIN"
groups:
- "ADMINISTRATORS"
- "EVERYONE"
tenant: "default"
source_acl:
- "ACL_READ"
- "ACL_WRITE"
description is optional and display-only: it's shown next to the username
in the persona login picker so you can tell
configured users apart at a glance (e.g. "Finance approver", "Read-only
auditor"). It is never a claim, never exported over SAML, and never part of
a token - just a note for the person picking a user, capped at 200
characters.
How these attributes end up in tokens, including the authority_prefixes
mapping below, is described in Tokens and claims.
Login mode (persona login)
password is optional on a user - a user without one can only sign in via
persona login mode, a local
dev/testing convenience (off by default) that lets every interactive login
surface (/login, /authorize, /saml/sso, /device) authenticate by
selecting a configured user instead of typing a password:
# settings.yaml
login:
mode: persona # default: password
# users.yaml
users:
admin:
password: "admin" # still works with either login mode
alice:
email: "alice@example.org" # no password: persona-mode only
description: "Standard internal user" # optional, shown in the picker
See the Security guide for the
full contract, including why the OAuth password grant is unaffected and
the SAML AuthnContextClassRef detail.
Auto-login
login.auto_login (default false) is a further opt-in on top of persona
mode, for driving a real OIDC client library in automated integration tests
(#250) - no username/password entry, no HTTP calls of your own into
nanoidp's login form:
# settings.yaml
login:
mode: persona
auto_login: true # default: false; has no effect unless mode is persona
With both set, an OIDC /authorize request whose login_hint is exactly
persona-auto-login:USERNAME logs that user in directly - no picker, no
HTML - straight to an authorization code. Any other login_hint is an
ordinary hint outside this feature and is left untouched, and with the flag
off a prefixed hint is inert too, so the picker still shows. An unknown
persona reports through the standard OAuth error redirect
(error=invalid_request, state preserved), never a bare 400. First
implementation surface is OIDC /authorize only - the device flow and SAML
have no equivalent transport for the hint today.
Two-step login
login.two_step (default false, #322/#323) collects the username on one
screen and the password on the next, for every interactive surface that
renders the combined form - /authorize, /login, /saml/sso and the
device flow's /device:
# settings.yaml
login:
two_step: true # default: false
A global setting, not a per-client one - it applies the same way to every
client. Inert under login.mode: persona above, which is passwordless and
has no password screen to split off. The step is stateless: the username
travels as a plain form field, carried forward as a hidden input on the
password screen, and whether a submission is the username-only step or a
real login attempt is derived from whether it carries a password - a
request that already has both authenticates directly, so a client written
against the combined form keeps working unchanged. "Change username"
returns to the first screen everywhere. It is a form post on every surface:
it discards a login waiting for its TOTP code, and on /saml/sso it
resubmits the in-progress SP request's hidden fields, since there is no GET
to link back to.
Second factor (TOTP)
login.totp (default false, #348) requires a time-based one-time code
after a successful password check, for any user carrying a totp_secret -
on the same four surfaces as two-step above. It is a declarative demo
factor, not IdP hardening (VISION principle 1): nanoidp is not a
production identity provider, and nothing here protects anything. It
exists so an application can be demoed and tested against a login that has
a real second screen and a real authenticator app, the way login.two_step
exists so a client can be tested against a split login form.
# settings.yaml
login:
totp: true # default: false
# users.yaml
users:
alice:
password: "alice-password"
totp_secret: "JBSWY3DPEHPK3PXP" # or ${ALICE_TOTP_SECRET}
Everything is data in the configuration files, nothing is managed at runtime, like every other property of a user:
- Enrolment is the secret's presence. There is no per-user
enabledflag, no enrolment screen, and no QR code in the UI - the operator writes the Base32 secret directly inusers.yaml(${VAR}placeholders work exactly as they do forpassword, including what a whole-entry save does to them: a form edit of an unrelated field or the MCPsave_configtool materializes an expanded${VAR}and rewrites a non-canonical spelling, such as spaces or lowercase letters, to the canonical one). To set the secret up in an authenticator app:- use its "enter a setup key" option with the same secret, or
- generate a QR code from the standard
otpauth://URI yourself:otpauth://totp/nanoidp:<username>?secret=<secret>&issuer=nanoidp&algorithm=SHA1&digits=6&period=30.
- Validation. The secret must be Base32 (case-insensitive, spaces and missing padding tolerated) or the configuration is rejected; a secret on a user with no password is rejected too - a factor has to follow a password.
- One rule, one place.
login.totprides the same phase machinery aslogin.two_step(routes/_auth.py): the code screen is a further phase after the password step. The verified password is never sent back to the browser: on/authorizeit is recorded on the authorization transaction (#346); on/login,/saml/ssoand/deviceit is recorded as a pending second factor (#373), bound to the browser, to the surface and to what the login is for there (the SAML request in flight, the deviceuser_code), valid for 5 minutes and used once. Either way the code screen carries only an opaque reference and the code, and the code is checked against the current secret of the user the record names. A user deleted or left without a secret, TOTP switched off or persona mode switched on in the meantime ends the login (access_deniedto the client on/authorize, an error on the page elsewhere) rather than completing it on the password alone. A form post carrying the password and the code together still completes both at once, with nothing stored. - Verification. RFC 6238, six digits, a 30-second period, HMAC-SHA1,
one step of clock skew either side - the parameters every authenticator
app assumes by default. Implemented with the standard library only
(
hmac,struct), no new dependency. - No replay memory. The same code is accepted more than once within
its 30-second step. A production TOTP implementation would track the
last accepted step per enrolment and refuse a repeat. A wrong code is
written to the audit log the way a wrong password is, and the code
screen is sent with
Cache-Control: no-store, since it carries the password forward. - Inert under persona mode, the same composition as
two_step: persona login checks no password, so there is nothing for a second factor to follow. - The OAuth password grant and client-credentials are unaffected - a
totp_secretonly changes the four interactive login surfaces above, nevergrant_type=password. - Claims follow the login, so a client can see the difference - see
Tokens for the
amrclaim and the security guide for theAuthnContextClassRefvalue (saml.mdcovers SAML options, not this per-method detail).
Out of scope, on purpose: enrolment/QR in the UI, encrypted secret storage,
a challenge store, replay protection, an administrator reset/recovery flow
(edit the file instead), and step-up on a session that logged in before
login.totp was turned on. A demo needs the visible flow. The rest is
what a production IdP needs.
Settings (config/settings.yaml)
server:
host: "127.0.0.1" # loopback by default; set 0.0.0.0 to expose on a network
port: 8000
rate_limit_enabled: false # optional; rate-limit POST /token (#304). Enforced
# for real since 3.0 - it used to log "enabled" while
# enforcing nothing. stricter-dev forces it on.
rate_limit_token_endpoint: "10/minute" # optional; flask-limiter notation
oauth:
issuer: "http://localhost:8000"
issuer_from_request: false # true: derive issuer/iss/verification_uri from
# each request's own Host header instead of the
# fixed issuer above - lets the same NanoIDP be
# reachable under more than one hostname (e.g. a
# Docker Compose service name vs. localhost) without
# a discovery/token issuer mismatch. MCP tools have
# no request of their own and always report the
# fixed issuer.
issuer_allowlist: [] # origins allowed to be reflected by issuer_from_request,
# e.g. ["http://localhost:8000", "http://nanoidp:9900"].
# Empty (default) allows any Host header; a non-matching
# Host falls back to the fixed issuer above.
device_verification_base_url: null # fixed, human-reachable URL (e.g.
# "https://idp.example.com") for the device flow's
# verification_uri, overriding issuer_from_request's
# derivation there - use this when a backend/container
# calls /device_authorization so the returned URL is
# still one a human's browser can open. Discovery's
# issuer and a token's iss are unaffected.
issuer_from_proxy_headers: false # true: trust X-Forwarded-Proto/Host/For from a
# single reverse-proxy hop in front of NanoIDP
# (applies werkzeug's ProxyFix). Only affects the
# issuer_from_request derivation above - has no
# visible effect unless that's also on - but always
# affects rate-limit client IP attribution. Only
# enable behind exactly one trusted proxy - these
# headers are otherwise spoofable.
audience: "my-app" # access token "aud" (resource audience, RFC 9068)
token_expiry_minutes: 60
refresh_token_expiry_minutes: 10080 # default 7 days, at most 30 - see "Lifetimes and CORS" below
refresh_token_rotation: false # true: each refresh invalidates the used refresh token
clients:
- client_id: "demo-client"
client_secret: "demo-secret"
description: "Default demo client"
- client_id: "multi-aud-client"
client_secret: "secret"
description: "Client whose ID Token carries extra audiences"
additional_audiences: # optional; makes the ID Token "aud" an array
- "https://api.example.com"
- "urn:service:billing"
- client_id: "registered-client"
client_secret: "secret"
description: "Client whose redirect_uri is pinned"
redirect_uris: # optional; when set, /authorize enforces
- "http://localhost:3000/callback" # exact string matching
- client_id: "branded-client"
client_secret: "secret"
description: "Demo client with custom login page branding"
background_color: "#2c3e50" # optional; hex only, behind the login card
header_color: "#3498db" # optional; hex only, the card's header band
footer_color: "#e8f4f8" # optional; hex only, the card's footer band
show_client_id: true # optional; default true
show_description: true # optional; default false
layout: "horizontal" # optional; "vertical" (default) or "horizontal" (#249):
# horizontal places the client info and login form side by
# side, collapsing back to a single column on narrow viewports
- client_id: "scoped-client"
client_secret: "secret"
description: "Client restricted to a scope subset"
allowed_scopes: # optional; see "Registered scopes" below
- "openid"
- "profile"
- client_id: "cli-client"
token_endpoint_auth_method: "none" # public client (#188): no secret
description: "CLI / SPA / MCP client that cannot keep a secret"
- client_id: "mcp-client"
client_secret: "secret"
description: "Client bound to specific MCP servers (RFC 8707, #187)"
allowed_resources: # optional; see "Resource indicators" below
- "https://mcp.example/server"
# logos_dir: "./static/logos" # optional; defaults to src/nanoidp/static/logos
# scopes_supported: # optional; the global scope vocabulary
# - openid # (default: openid, profile, email, offline_access)
# - profile
# - email
# - offline_access
# scope_enforcement: true # optional; false is a dev-only escape
# hatch back to "any scope string is
# accepted" - see "Registered scopes"
# dynamic_registration: # optional; RFC 7591/7592 at POST /register.
# enabled: false # Off by default, and set in this file only:
# max_clients: 100 # not in the settings form or the MCP tools.
# client_id_metadata_documents: # optional; a client_id that is an https URL
# enabled: false # is fetched at /authorize. File only, too.
# allowed_hosts: [] # exact host[:port]; nothing is fetched
# allow_loopback: false # until a host is named here
saml:
# Both optional: when absent they are derived from the effective issuer as
# <issuer>/saml and <issuer>/saml/sso, so they follow issuer_from_request and
# the reverse-proxy settings exactly like OIDC discovery does (#181). Set
# them only when the SP needs a different, fixed value.
# entity_id: "http://localhost:8000/saml"
# sso_url: "http://localhost:8000/saml/sso"
default_acs_url: "http://localhost:8080/login/saml2/sso/nanoidp"
sign_responses: true # Set to false for testing unsigned SAML flows
want_authn_requests_signed: false # verify AuthnRequest signatures (see SAML options)
# sp_certificates: # PEM files, required when the above is true
# - /path/to/sp-cert.pem
export_roles: false # include the user's roles as a SAML attribute
export_groups: false # include the user's groups as a SAML attribute
roles_attr_name: "roles" # attribute name used when export_roles is on
groups_attr_name: "groups" # attribute name used when export_groups is on
# Optional; also settable at startup with --profile, which wins over this value
# for that run only (any of the three, including an explicit dev), is never
# written back here and survives every reload (#172)
# security_profile: oauth21 # dev (default) | stricter-dev | oauth21
# Optional; absent, the security profile decides - see "Lifetimes and CORS" below
# cors_allowed_origins:
# - "http://localhost:5173"
# Optional; the device authorization grant's timing - see "Lifetimes and CORS" below
# device_flow:
# code_expiry_seconds: 600 # default; 1 to 3600
# polling_interval: 5 # default; 1 to 60
# Optional; local dev/testing convenience, off by default - see "Login mode" above
# login:
# mode: persona # password (default) | persona
# auto_login: true # default: false; requires mode: persona - see "Auto-login" above
# two_step: true # default: false; see "Two-step login" above
# totp: true # default: false; see "Second factor (TOTP)" above
# Optional; how an unknown key is reported - see "Validating your configuration"
# below. Also settable at startup with --strict-config, which wins over this
# value for that run only and is never written back
# config_validation: strict # warn (default) | strict
authority_prefixes:
roles: "ROLE_"
groups: "GROUP_"
identity_class: "IDENTITY_"
entitlements: "ENT_"
# Optional; the identity-class values selectable when editing a user
# (web UI and user forms). Defaults to the four values below.
allowed_identity_classes:
- "INTERNAL"
- "EXTERNAL"
- "PARTNER"
- "SERVICE"
logging:
verbose_logging: true # Include usernames/client_ids in logs (default: true)
Registered redirect URIs: a client with a non-empty redirect_uris
list gets exact-string matching on /authorize (RFC 6749 §3.1.2.3,
OAuth 2.1 §4.1.1): no prefix, host or path normalization, and a mismatch
is answered with 400 invalid_request directly, never by redirecting to
the unvalidated URI (§3.1.2.4). Clients without the field keep accepting
any absolute URI, the permissive dev default.
Registered scopes (issue #186): oauth.scopes_supported is the global
scope vocabulary (default: openid, profile, email, offline_access,
also what discovery's scopes_supported advertises); a client's
allowed_scopes is an optional subset of it. A requested scope outside the
vocabulary is invalid_scope for every client (RFC 6749 §3.3, §4.1.2.1,
§5.2) - scopes_supported is a contract, not a suggestion. A requested
scope outside a client's own allowed_scopes, when set, is invalid_scope
for that client specifically; a client without the field may obtain any
vocabulary scope, the permissive dev default (same "empty = unrestricted"
convention as redirect_uris above). Enforced at /authorize, every
/token grant (including client_credentials, RFC 6749 §4.4), and
/device_authorization. oauth.scope_enforcement: false is a dev-only
escape hatch back to the pre-#186 behavior - any scope string accepted,
unchecked; refused outside the dev profile.
Public clients (issue #188): token_endpoint_auth_method: "none"
declares a client that cannot keep a secret - a CLI, desktop or native
app, SPA, or MCP client. client_secret becomes optional (and is ignored
if present: a stored secret never authenticates a public client);
/token identifies the client by client_id alone. In exchange, the
protections that replace client authentication are mandatory regardless
of profile: /authorize requires PKCE with S256 (OAuth 2.1 §7.5.1),
the client_credentials grant is refused with unauthorized_client
(it IS client authentication), and refresh tokens always rotate with
reuse detection (OAuth 2.1 §4.3.1/§6.1), whatever
refresh_token_rotation says. /revoke accepts a public client's
client_id with an ownership check - the token's own client_id claim
must match, otherwise the response is still 200 and nothing is revoked
(RFC 7009 §2.1 and its privacy guidance). /introspect stays
authenticated (RFC 7662): a public client_id is not authentication.
/device_authorization accepts a public client by client_id alone
(RFC 8628 §3.1, #255) - it presents the client_id as a parameter, not
via HTTP Basic or a secret, and the issued device_code is bound to it.
A confidential client authenticates on every grant, authorization_code
included (RFC 6749 §3.2.1); a missing or wrong secret is invalid_client.
The registered token_endpoint_auth_method is enforced (RFC 7591) at
every client-authenticated endpoint - /token, /introspect,
/revoke and /device_authorization (issue #262): the default
client_secret_basic requires HTTP Basic and rejects a body secret,
while client_secret_post requires client_id + client_secret as POST
body parameters and rejects Basic; presenting both HTTP Basic and a body
secret in one request is rejected (RFC 6749 §2.3). Applying the one
registered method across all of these endpoints is nanoidp's consistency
policy, not an obligation of every RFC: RFC 7009 and RFC 8628 do tie
/revoke and /device_authorization to the token-endpoint method, while
RFC 7662 requires some client authentication at /introspect but leaves
the method open - nanoidp reuses the registered method there too rather
than introduce a second field. A client that authenticated to these
endpoints over the other channel before must now use the channel its
token_endpoint_auth_method names (or be re-registered for the method it
actually uses).
Public-client handling differs by endpoint (each follows its own RFC), so here it is in one place:
| Endpoint | A public client (token_endpoint_auth_method: "none") |
|---|---|
/authorize | Accepted; PKCE with S256 is mandatory regardless of profile (OAuth 2.1 §7.5.1). |
/token - authorization_code, refresh_token, device_code | Identified by client_id alone; refresh tokens always rotate with reuse detection. |
/token - client_credentials | Refused with unauthorized_client (the grant IS client authentication). |
/device_authorization | Accepted by client_id alone (RFC 8628 §3.1); HTTP Basic or a client_secret is rejected; the device_code is bound to the client. |
/revoke | Accepted by client_id alone; an ownership check on the token's client_id claim stands in for authentication (RFC 7009 §2.1). |
/introspect | Refused - a public client_id is not authentication (RFC 7662); none is not in introspection_endpoint_auth_methods_supported. |
Authorization response iss (issue #189, RFC 9207): /authorize
returns iss=<effective issuer> on every response delivered through a
validated redirect_uri - success and error alike - so a client can
detect an authorization-server mix-up. iss is delivered exactly when
discovery advertises authorization_response_iss_parameter_supported: a
single condition drives both, so metadata and behaviour never disagree.
RFC 9207 requires the issuer to be an https URL with a host and no query
or fragment, so the default http://localhost:8000 dev issuer sends no
iss and advertises false; point the issuer at https (directly, or
reflected via issuer_from_request behind a TLS proxy) to turn RFC 9207
on. The value follows issuer_from_request. Errors are OAuth error
redirects (error, error_description, state, iss) once the
redirect_uri is validated; an error before that - an unknown client, a
malformed or unregistered redirect_uri - stays a local JSON response,
never a redirect to an unvalidated URI.
Resource indicators (issue #187, RFC 8707): a client may send one or
more resource parameters on /authorize, /token and
/device_authorization, and the access token's aud is bound to those
resources - a token minted for one MCP server is then rejected by
another. A resource must be an absolute URI without a fragment,
otherwise the request is invalid_target. Per-client allowed_resources
gates which resources a client may target (empty = any valid resource,
the dev default, same "empty = unrestricted" convention as
allowed_scopes). A /token request may narrow the resources a prior
step bound (an authorization code, a refresh token) but never widen them.
Sending no resource leaves aud at oauth.audience, unchanged for
existing clients. /introspect reports the token's aud; there is no
resource_indicators_supported discovery metadata (RFC 8707 defines
none). An MCP client sends resource on both /authorize and /token
(MCP Authorization).
Native apps (RFC 8252): two things a native client needs are built
in. A private-use scheme URI such as com.example.app:/oauth2redirect
(§7.1: a scheme and a path, no host) is a valid redirect_uri and can be
registered like any other. §7.1 requires such schemes to be a domain the
app controls, in reverse order; NanoIDP applies the minimum rule the RFC
asks of an authorization server, rejecting any non-http(s) scheme that
contains no period (myapp://callback is answered with 400 invalid_request naming the rule), and does not verify domain ownership.
A registered loopback URI,
http://127.0.0.1:{port}/callback or http://[::1]:{port}/callback,
matches any port (§7.3), because the app binds an ephemeral port at
runtime; register it with any placeholder port (:0 reads well). Only
the port is variable: scheme, host, path and query stay exact, and
localhost gets no port flexibility (§7.3 and §8.3 recommend the IP
literals precisely because localhost can be remapped). The oauth21
profile keeps the loopback exception, as the OAuth 2.1 draft does.
- client_id: "native-client"
client_secret: "secret"
redirect_uris:
- "com.example.app:/oauth2redirect" # private-use scheme, exact
- "http://127.0.0.1:0/callback" # loopback: any port matches
Login page branding: for demos and prototyping, a client can show its
client_id and description on the /authorize login page and use custom
colors, so testers can see which application they're signing in to. All
fields are optional and safe by construction: colors must be a plain
#rrggbb hex string (validated on save, rejected otherwise), never raw CSS
or markup, and a logo is a local file, never a remote URL. To add a logo,
drop an image at <logos_dir>/<client_id>.{svg,png,jpg,jpeg,webp} (default
logos_dir: src/nanoidp/static/logos, overridable via oauth.logos_dir);
it's picked up by filename, no config entry needed.
layout (#249) controls the card's composition: "vertical" (default) is
the single-column card - header, client info, then the login form, footer;
"horizontal" places the client info and the login form side by side, with
the header and footer still full width, collapsing back to the vertical
stack on narrow viewports. It's one of exactly two nanoidp-owned layouts,
not a general styling knob - there's no per-client CSS or column widths.
Two-step login (splitting the password form into a username screen and a
password screen) is a global login.two_step setting, not part of a
client's branding - see "Two-step login" above. The same is true of the
declarative TOTP second factor, login.totp - see "Second factor (TOTP)"
above.
To preview a client's branded login page, open /authorize with its
client_id and a redirect_uri (any syntactically valid URL works unless
the client has redirect_uris pinned - see above):
http://localhost:8000/authorize?response_type=code&client_id=branded-client&redirect_uri=http://localhost:3000/callback
The page won't complete the flow (there's no app listening at
redirect_uri to receive the code), but it renders the branding, which is
all a visual check needs. This only affects /authorize; the dashboard's
own /login and the SAML SSO login page are unbranded.
The SAML options (strict_binding, sign_responses, c14n_algorithm)
are covered in detail in SAML options. The two ways a client can
exist without being declared here have a guide each:
Dynamic client registration
(oauth.dynamic_registration) and
Client ID metadata documents
(oauth.client_id_metadata_documents). Security-related
settings are covered in the Security guide:
profiles, require_pkce, key management, jwt.external_keys, the
config UI's opt-in login gate
(session.require_ui_login), the invalid-bcrypt-hash
fallback removal
(session.enforce_password_check), and the
management secret write guard
shared by MCP, /api/*, and the web UI (session.management_secret) - all
YAML-only, like session.secret_key (see
Session Cookie Trust
for why that one matters here too).
Lifetimes and CORS
These settings default to what nanoidp did before they could be set (#441), so a file that does not mention them behaves as it always did.
oauth.refresh_token_expiry_minutes(default 10080, 7 days; 1 to 43200): the lifetime of every refresh token issued from now on, a rotated one included. A token already issued keeps the lifetime it was issued with. A short value is how a test sees a client handle an expired refresh token.device_flow.code_expiry_seconds(default 600; 1 to 3600) anddevice_flow.polling_interval(default 5; 1 to 60): what/device_authorizationannounces asexpires_inandinterval(RFC 8628), and how long a device code and its user code stay valid. Withcode_expiry_seconds: 1, a test seesexpired_tokena second later.cors_allowed_origins: the origins a browser may call nanoidp from. Absent, the security profile decides: every origin underdevandoauth21,localhostand127.0.0.1on any port understricter-dev. Present, it is the list in every profile:[]allows no origin,"*"every origin, and each entry is an origin written the way a browser sends it in an Origin header:httporhttps, a host (a name, an IPv4 address, or a compressed IPv6 address in brackets such as[::1]) and a port only when it is not the scheme's default. Entries are compared with the Origin header as strings, ignoring case, so one in another form would never match and is refused with the form to write instead (https://app.example:443becomeshttps://app.example). An internationalised host is written in the ASCII form the browser sends (https://xn--fa-hia.deforfaß.de); a path, credentials or a pattern such ashttp://localhost:*is refused as not an origin. A"*"understricter-devis logged as a warning at startup: the list wins over the profile. CORS is set up when the server starts, so a change takes effect at the next restart, not at a reload.
GET /api/config and the MCP get_settings tool report all four; the
settings page edits the refresh token lifetime, and MCP update_settings
the refresh token lifetime and the two device flow values.
Two keys that used to be accepted and ignored are not settings:
logging.format (the log format is fixed) and session.permanent (the
login session is always a permanent cookie). They are reported like any
unknown key: a warning, or a refusal under config_validation: strict.
Runtime store (runtime:)
runtime:
store: memory # the default
runtime:
store: sqlite
path: ../state/nanoidp-runtime.db # relative to this configuration directory
Where runtime state is kept: runtime users and clients, authorization codes, device codes, revocations, the audit, and the rest of what a running IdP records that is not declared in these files. For what sharing one buys and what it does not, see the guide Two processes, one runtime state.
memorykeeps it in the process and loses it on restart, as NanoIDP always has.sqlitekeeps it in files that several NanoIDP processes on one host share:pathnames the database, and the store is that file, an audit file next to it and a directory of owner leases, named after it (runtime.dbhasruntime-audit.dbandruntime-owners/).
path. It is required with sqlite and refused with memory. A relative
path is relative to the configuration directory, not to the working directory
of the process: it is the identity of a store several processes share, and
the same settings.yaml must name the same store however each process was
started. For the same reason a path starting with ~ is refused: it would
name a file of each process's HOME. None of the store's files may lie inside the configuration directory
(symlinks resolved): they are a secret, and the configuration directory is
read, copied and committed as configuration. The files are created 0600
(the leases directory 0700); delete all of them to start afresh.
Choosing and changing it. The store is chosen when the process starts. A
reload that asks for another store or another file is refused (422, kind
activation, with a message that says to restart), because processes that
share one would otherwise disagree about what exists. GET /api/config and
the MCP get_settings tool report the store in use as runtime.store, and
for sqlite the file the process opened as runtime.path. The section is YAML-only:
the settings form and MCP do not write it. Audit events recorded before the
store is activated (by a plugin's load hook) are kept in memory until then,
and are not carried into a SQLite store: a warning says how many.
When it is held. A store held by another process for longer than it waits
is 503 with Retry-After over HTTP, and MCP_RUNTIME_STORE_UNAVAILABLE
with retryable: true in MCP.
Logging
NanoIDP logs all authentication events to both the audit log (viewable in the web UI) and standard output.
logging:
level: INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_token_requests: true # Log token endpoint requests
log_saml_requests: true # Log SAML endpoint requests
verbose_logging: true # Include usernames/client_ids in log messages
The format of the log lines is fixed; logging.format is not a setting.
Verbose logging (verbose_logging: true, default):
- Log messages include user and client identifiers for debugging
- Example:
[login] POST /token - success (user: admin) (client: demo-client)
Non-verbose logging (verbose_logging: false):
- Log messages omit sensitive identifiers
- Example:
[login] POST /token - success
Set verbose_logging: false if you're concerned about PII in log files,
though for a dev tool this is typically not an issue.
Hooks and plugins (hooks:, plugins:)
Two optional top-level sections, absent by default, declare the extension
points described in Extending nanoidp: hooks and plugins:
hooks: holds shell commands for on_before_load, on_config_saved and
on_audit_event plus strict and timeout_seconds (shell hooks only);
plugins: maps a plugin's entry-point name to its own settings (the only
section whose inner keys nanoidp does not validate). Both are YAML-only:
GET /api/config and the MCP get_settings tool report hook names,
sources and failure counters, never the commands (which may embed expanded
${VAR} secrets), and neither surface can change them. Policy values
declared here override bootstrap.yaml's; undeclared ones keep the
bootstrap value. Hooks
that must run before settings.yaml exists go in bootstrap.yaml (same two
keys) or in NANOIDP_BOOTSTRAP_HOOK / NANOIDP_BOOTSTRAP_PLUGIN.
hooks:
on_config_saved: "git -C {config_dir} add {path} && git -C {config_dir} commit -q -m 'nanoidp: {kind}' || true"
strict: false
timeout_seconds: 10
plugins:
echo:
record: /tmp/nanoidp-hooks.jsonl
Placeholders and the config directory as the interface
Both files accept ${VAR} and ${VAR:default} placeholders in any scalar
value except config_version (a literal integer, see above), expanded from
the environment when the file is loaded:
# settings.yaml
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000}
clients:
- client_id: my-app
client_secret: ${MY_APP_SECRET} # no default: empty when unset
# users.yaml
users:
alice:
password: ${ALICE_PASSWORD} # unset: load fails, a password cannot be empty
email: ${ALICE_EMAIL:alice@example.org}
What a save does to placeholders differs between the two files. In
settings.yaml a web UI or MCP save rewrites only the fields it changed, so
untouched placeholders survive. In users.yaml a save of one user rewrites
that user's entry from its loaded (expanded) values and leaves every other
user's text intact; the MCP save_config tool rewrites the whole user map
and therefore materializes every expanded placeholder in it. Keep
placeholder-backed users out of the UI/MCP edit path, or regenerate the
file from its source after editing.
This makes the config directory the whole interface between NanoIDP and whatever produces its configuration. Three use cases that need nothing beyond it:
- One file, many environments: commit
settings.yamlwith placeholders and set the variables per environment (shell, Composeenvironment:, a Kubernetesenv:block). - Secrets kept out of the file: point the placeholder at a variable that an init step renders from wherever the secret lives; NanoIDP only ever sees the environment.
- Files produced elsewhere: generate or copy both YAML files into
NANOIDP_CONFIG_DIRbefore start (an init container, a mounted volume, a script), thenPOST /api/config/reloador the MCPreload_configtool to pick up a later change without a restart. Reloading re-reads the files, re-expands placeholders and re-applies the CLI--profile. A reload that does not validate, or whose signing configuration cannot be used, answers422and leaves the running configuration in place.
NanoIDP does not read from or write to any store other than these files; a sync with an external system is the deploy's job, on either side of the directory.
Environment variables
The environment variables (NANOIDP_CONFIG_DIR, NANOIDP_MANAGEMENT_SECRET,
the legacy NANOIDP_MCP_ADMIN_SECRET alias, NANOIDP_MCP_READONLY, PORT)
are listed in the
Security guide.