psd reads one JSON file (serve --config, default psd.json) plus a
handful of environment overrides. psd example-config prints a starting
point (the file itself). Every struct rejects unknown
fields and every value is validated at load, so a typo is a startup error
with a message that says what to change, never a silently ignored setting.
Merge order is fixed: file → environment → validate. The environment
wins over the file so a deployment can inject a hostname or a path without
editing it.
Environment overrides
Variable
Overrides
PSD_ISSUER
issuer
PSD_LISTEN
listen
PSD_KEYS_FILE
keys_file
PSD_DB_PATH
storage.path
PSD_TELEMETRY_ENABLED
telemetry.enabled (1 or true)
OTEL_EXPORTER_OTLP_ENDPOINT
telemetry.endpoint (only when unset in the file)
OTEL_SERVICE_NAME
telemetry.service_name (only when unset in the file)
Top level
Field
Default
Meaning
issuer
(required)
The server identifier: https://host, lowercase, no port, no path, no trailing slash. Permanent — it is in every derived sub and every token’s iss, and this exact origin must serve /.well-known/aauth-person.json. Must be a hostname: passkeys do not work on IP-address origins.
listen
127.0.0.1:8430
Bind address. Use 0.0.0.0:8430 in a container.
keys_file
psd-keys.json
The file psd keygen writes: signing keys plus the pairwise secret. Read at startup.
expected_authority
(issuer host)
The @authority (Host) inbound signed requests must carry. Set only when a TLS-terminating proxy rewrites Host — better to make it preserve Host. The check is what makes the mandated @authority component prevent cross-host replay, so it is never simply off. Lowercase host, optional :port.
insecure_dev_mode
false
Development only. Accepts an http:// issuer (and ports), allows outbound fetches over http and to private/loopback addresses, permits a non-Secure session cookie. serve warns while it is on.
max_body_bytes
65536
Largest request body accepted (min 1024).
jwks_cross_origin_hosts
[]
Bare hostnames admitted as cross-origin jwks_uri hosts when verifying foreign tokens (an issuer whose metadata points its JWKS at a CDN). Empty means same-origin JWKS only, per the Signature-Key draft.
audit_log_file
(unset)
Append the structured JSON audit events to this file, in addition to stderr.
Token lifetimes and windows
Field
Default
Meaning
person_token_ttl_secs
3600
Person-token lifetime. The draft says person tokens MUST NOT live longer than one hour, so 1..=3600. The issued exp is further capped by the presenting agent token’s exp and, under a mission, by the mission’s expires_at.
auth_token_ttl_secs
3600
Auth-token lifetime, same one-hour ceiling and the same caps.
signature_window_secs
60
How far a request signature’s created may be from now (1..=3600). Also the window of the replay guard on body-carrying requests.
resource_token_max_age_secs
300
The longest resource-token lifetime (exp − iat) accepted at /token (1..=3600; the draft says resource tokens SHOULD NOT exceed five minutes). This is also the retention floor: person-token records are kept at least this long past their exp, so a resource token naming one can still be checked.
retention_slack_secs
3600
Extra retention beyond the floor for clock skew and slack. A person-token record is purged after exp + resource_token_max_age_secs + retention_slack_secs; forgotten early, a valid resource token is wrongly refused; never forgotten, the table grows without bound.
storage
Field
Default
Meaning
storage.backend
sqlite
The only backend in this build. postgres is planned and rejected at load, so a deployment cannot believe it is using it.
storage.path
psd.db
SQLite file. :memory: is accepted for tests and throwaway runs (state is lost at exit; the CLI refuses it). psd opens it in WAL mode.
directed_sub and person_auth
Field
Default
Meaning
directed_sub.mode
pairwise
The only mode: sub is derived per (person, audience) with the pairwise secret, so two services cannot correlate a person. Any other value is rejected.
person_auth.method
passkey
The only method in this build. oidc (organisation login) is planned and rejected at load.
notify
Field
Default
Meaning
notify.channels
["web"]
How the person is reached when a decision is pending. web: it waits on the dashboard. webhook: additionally POST a JSON notification to webhook_url. At least one channel is required.
notify.webhook_url
(unset)
Required, and must be https://, when webhook is listed (http:// only in dev mode). The payload names the pending request, the agent and the resource; it never carries the interaction code.
missions and federation
Field
Default
Meaning
missions.enabled
false
Advertise and serve mission_endpoint. Off by default: presence of the endpoint in metadata is how a Person Server says it supports missions. When off, mission_s256 on /person is refused as an unsupported parameter rather than silently ignored.
missions.default_ttl_secs
86400
The lifetime pre-selected on the mission approval screen. The person may choose a shorter or longer one, or none.
federation.enabled
false
Four-party: when a resource token’s aud names an Access Server, obtain the person’s consent and then federate to it; also enables call chaining via upstream_token. Exercised against a mock Access Server only, since no live one exists yet.
limits
Field
Default
Meaning
limits.resources_per_agent_per_day
50
Distinct resource values one agent may obtain person tokens for per rolling day (the draft says a PS SHOULD rate-limit: each obliges a derived and retained directed sub). A resource the agent already holds a token for is never counted. Over the limit: 429 with Retry-After.
limits.code_attempts
5
Failed interaction-code presentations before the pending interaction is terminally failed (the draft says the PS MUST rate-limit code validation).
limits.pending_ttl_secs
600
How long a deferred request waits for the person before it expires (min 30).
ui
Field
Default
Meaning
ui.session_ttl_secs
43200
Browser session lifetime (min 60). Sessions are HttpOnly; SameSite=Lax; Secure cookies with a CSRF token on every POST.
ui.templates_dir
(unset)
Directory of HTML templates that override the built-in ones by file name (consent screen, dashboard, …). Built-ins are embedded and used for anything not present. Loaded at startup; a missing directory is an error, a partial one is fine. See Templates & branding.
metadata
Display fields published in /.well-known/aauth-person.json and shown on
psd’s own screens. All optional; every URI must be https://.
Field
Meaning
metadata.name
The server’s human name — on every page title and the consent screen. Set it.
metadata.description
Markdown, one or two sentences. Consumers must sanitize before rendering; psd does the same with theirs.
metadata.logo_uri, metadata.logo_dark_uri
Logos for light and dark themes.
metadata.documentation_uri
Where a reader learns what this server is. The example config points at https://personserver.dev/docs/.
metadata.tos_uri, metadata.policy_uri
Terms and privacy policy.
telemetry
Accepted and validated now so a file written for the release that wires it
in parses today; not yet emitting anything.
Field
Default
Meaning
telemetry.enabled
false
Master switch (PSD_TELEMETRY_ENABLED).
telemetry.endpoint
(unset)
OTLP/HTTP base, http:// or https:// (OTEL_EXPORTER_OTLP_ENDPOINT).
telemetry.service_name
psd
(OTEL_SERVICE_NAME)
telemetry.metric_interval_secs
30
The example config
psd example-config prints this; psd.example.json is
the same file.
{"issuer":"https://ps.example.com","listen":"127.0.0.1:8430","keys_file":"/var/lib/psd/psd-keys.json","storage":{"backend":"sqlite","path":"/var/lib/psd/psd.db"},"person_token_ttl_secs":3600,"auth_token_ttl_secs":3600,"signature_window_secs":60,"resource_token_max_age_secs":300,"retention_slack_secs":3600,"directed_sub":{"mode":"pairwise"},"person_auth":{"method":"passkey"},"notify":{"channels":["web"],"webhook_url":null},"limits":{"resources_per_agent_per_day":50,"code_attempts":5,"pending_ttl_secs":600},"ui":{"session_ttl_secs":43200,"templates_dir":null},"missions":{"enabled":false},"federation":{"enabled":false},"metadata":{"name":"Example Person Server","description":"Manage which agents act for you and review what they do.","documentation_uri":"https://personserver.dev/docs/"},"telemetry":{"enabled":false,"endpoint":"http://localhost:4318"},"insecure_dev_mode":false}
The keys file
Written by psd keygen, mode 0600, and read at startup:
--rotate adds a new active key and keeps the old ones (their public halves
stay in the JWKS so tokens signed before rotation still verify);
--prune-days N drops keys older than N days. keygen on an existing file
that lacks a pairwise_secret adds one. Nothing ever changes an existing
pairwise_secret: it is what makes every directed sub stable.