Security
Northplane ships with safe defaults — loopback listener, no plaintext on the network without an explicit decision, argon2id everywhere, encrypted secrets, a hash-chained audit log, strict CSP — but a production instance still needs a few deliberate choices. This page is the checklist; each item links to the page that explains the mechanism.
Hardening checklist
Section titled “Hardening checklist”- Terminate TLS — either a certificate pair in
tls.*or a trusted reverse proxy withtrustProxy: true; nevertls.insecureoutside development. SetbaseUrlto the publichttps://URL. → TLS and reverse proxies - Harden host SSH by identity, not source IP —
deploy/harden-access.sh(key-only auth, rescue sshd on 2222, opt-in port-based firewall with auto-rollback) is described in Provisioning. - Expose only 443 — keep 8443 (the listener) reachable from the proxy only; open 9162/udp, 2023, 8123 or 4573 only when you actually run the trap receiver, ESPA, ESPA-X or FastAGI listeners, and only towards the devices that need them. → Deployment overview
- Decide how the first admin is created — default-admin seeding with
NP_DEFAULT_ADMIN_EMAIL/NP_DEFAULT_ADMIN_PASSWORD(and rotate that password after first login), orNP_DEFAULT_ADMIN_DISABLED=1and the interactive/setup, ornorthplaned bootstrap-adminheadless. Never leave a generated password only in the logs. → Authentication - Keep self-service signup off unless you want it:
allowSignup: falseis the default; the production instance in Environments follows the repo variableNORTHPLANE_ALLOW_SIGNUP(default off). Self-registered users only getviewer. - Use SSO or directory login for people (OIDC with
adminGroup/idpGroupsmapping, or LDAP withdisableMissing: true) and keep one local break-glass admin. → Authentication, Users, roles and permissions - Scope API tokens to the permissions they need, set
expiresAt, useipBindwhere the caller address is stable (remember it is the TCP peer, i.e. the proxy’s address behind a proxy), rotate with:rotate, and never reuse the*:*bootstrap token for integrations. Agents needobjects:writeonly; MCP tokens get theaiAgentflag. → API tokens - Protect and back up
secret.key— mode0600, owned by the service user (uid 65532 in containers), copied to a safe place; without it every sealed secret is lost. Put credentials into the secret store and reference them as$SECRET:name$instead of pasting them into channel or source configs. → Secrets - Authenticate every event source —
authMode: token(header, not query string) orhmac;noneonly for sources that are reachable from trusted networks alone. Set the Twilio auth token for inbound telephony so signatures are verified, andallowFromfor caller allow-lists. → Event sources, Voice and IVR - Sign outgoing webhooks — give webhook subscriptions a
secretand verifyX-Northplane-Signatureon the receiving side. → Outgoing webhooks - Restrict the anonymous endpoints at the proxy if your threat model requires it —
/metrics,/api/v1/system/health,/api/v1/system/info,/api/docs,/status/default. See Unauthenticated endpoints. - Keep the audit chain verifiable — run
np audit verify(orPOST /api/v1/audit:verify) on a schedule and export the log to your SIEM withGET /api/v1/audit:export. → Observability - Back up regularly —
northplaned backupplussecret.keyandconfig.yaml; there is no built-in schedule. → Storage → Backup - Upgrade deliberately — pin image tags, read release notes, back up first. → Upgrades
- Review the known gaps below and decide whether they matter for your deployment.
Transport
Section titled “Transport”- Plaintext on a non-loopback listener is refused unless
trustProxyortls.insecureis set; with a certificate pair the minimum protocol is TLS 1.2 and an unloadable pair is fatal (no silent fallback). trustProxytrusts onlyX-Forwarded-Proto(first value). It must be enabled only when the proxy is the sole path to the listener and overwrites inbound forwarded headers — otherwise a client could obtainSecurecookies and HSTS over plaintext by sending the header itself.- HSTS (
max-age=31536000; includeSubDomains) is sent on HTTPS responses;X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: same-originalways; a strict CSP on the UI and pages;/api/*carries no CSP. Details and the verbatim policies: Security headers. - Cloudflare or another CDN in front is fine: Caddy should declare it as a trusted proxy for its own logs; Northplane does not read client IPs from headers at all.
Accounts and credentials
Section titled “Accounts and credentials”- Passwords: argon2id (
time=1, 64 MiB, 4 threads, 32-byte key, 16-byte salt), minimum 12 characters, constant-time verification against a dummy hash for unknown accounts; login attempts are rate-limited per client IP (burst 8, one per 15 s). A disabled account cannot log in or keep using a session. - Sessions: server-side rows,
np_sessioncookieHttpOnly+SameSite=Lax+Secure(on HTTPS), 12 h or 30 days with “remember me”; logout deletes the row. Password changes do not invalidate other sessions — disable the user or wait for expiry if a session must die now. - The break-glass admin is re-seeded on every start when no enabled local admin exists; the last enabled local admin cannot be disabled or deleted (
409 np:users/last-admin). - Roles:
operatorandviewerhold noadmin:*;config:write(templates, rules, channels, bundles) is admin-only among the built-ins; custom roles can nest (includes) and map IdP groups (idpGroups). Token permissions = scopes ∪ role permissions. - API tokens:
np_+ 48 hex characters, stored as prefix + argon2id hash, shown once;expiresAt,ipBind,aiAgent,lastUsedAt;:rotateissues a new secret and deletes the old one immediately. MCP over HTTP and stdio both authenticate with these tokens and inherit exactly their permissions. - Secrets: AES-256-GCM under the 32-byte master key, write-only through the API (
GET /api/v1/secretsreturns names only), tenant-scoped, never logged (auditsecret.putcarries no value). The agent check pull (GET /api/v1/agent/checks) does not expand$SECRETreferences.
Ingest and integrations
Section titled “Ingest and integrations”- Generic webhook ingest (
POST /api/v1/ingest/{source}) authenticates per source:token(default —Authorization: Bearer <secret>or?token=; the query form leaks into access logs, prefer the header or HMAC),hmac(X-Northplane-SignatureorX-Hub-Signature-256, HMAC-SHA256 over the raw body, hex, optionalsha256=prefix),basic(password compared, username ignored),none. An empty secret makestoken/hmac/basicfail closed; a disabled secret store (no master key) has the same effect. Rate limits are per source (rateLimitdefault 50/s,burst200). - Avoid ingest secrets starting with
np_— the API middleware would treat them as API tokens and answer401. - Source names are resolved across all tenants for ingest URLs (first match by slug order), so treat event-source names as globally unique.
- The Alertmanager receiver (
…/alertmanager) does not check the source’senabledflag (only its auth) — disable by removing the secret or the source if you need to stop it. - Inbound telephony: Twilio signatures are verified only when
config.twilioAuthTokenis set (as$SECRET:…$);allowFromrestricts callers (403 np:ingress/caller).baseUrlmust match the public URL or signatures fail. - Ack links (
GET /api/v1/ack/{token}) are HMAC-signed with a server secret, valid 24 h, act only on open alerts and remain valid until expiry (re-clicking shows the same confirmation); the DTMF callback uses the same token. Do not forward notification e-mails containing ack links to untrusted recipients. - Outgoing webhooks are signed with
X-Northplane-Signature: sha256=<hex>when the subscription has asecret; deliveries go through the outbox with retries and a dead-letter queue (Reliability). - Bundle apply tokens (
ap_…) expire after 10 minutes and are single-use; bundle bodies are capped at 8 MiB. - Discovery scans refuse loopback, link-local and multicast ranges and anything larger than a /20.
Browser protections
Section titled “Browser protections”- The SPA is gated server-side: unauthenticated document navigations redirect to
/login; API calls never redirect (they get401problem documents). - CSRF: session-cookie requests whose browser sends
Sec-Fetch-Site: cross-siteare rejected (403 np:auth/csrf); cookies areSameSite=Lax; there is no CORS — the API cannot be called from another origin with the user’s cookie, and token clients are expected to be server-side. Raw routes (ingest, ack link, health) are not CSRF-checked because they do not use sessions. frame-ancestors 'none'/X-Frame-Options: DENYprevent clickjacking; the only third-party origin in the CSP isapp.stepped.ai(the embedded assistant widget).- Every API response carries
X-Request-Id, also stored in audit entries — useful for incident forensics.
Unauthenticated endpoints
Section titled “Unauthenticated endpoints”Reachable without any credential (restrict at the proxy if needed):
| Endpoint | What it exposes |
|---|---|
GET /healthz, GET /readyz |
liveness/readiness; /readyz names the storage dialect |
GET /metrics |
server self-metrics (request counts, queue depths, TSDB stats) |
GET /api/v1/system/health |
queue depths and subsystem counters |
GET /api/v1/system/info |
version, Go version, goroutines, heap, uptime, storage dialect, aiEnabled |
GET /api/openapi.json, GET /api/docs, GET /api/docs/{asset} |
the API specification and Swagger UI |
GET /docs/ |
this documentation |
GET /status/default (and any /status/{slug} configured as public) |
a public status page for the default tenant: business-service root names with a coarse state, or an aggregate “Infrastruktur” row; German text, Cache-Control: max-age=30. Non-public pages require ?token=; there is no API or UI to configure status pages in this version, so only default exists unless the kv entry statuspage/<slug> is written directly |
GET/POST /login, /setup (while open), /register (when allowSignup), /auth/oidc, /auth/callback, /auth/logout |
authentication pages |
POST /api/v1/ingest/{source}, POST /api/v1/ingest/{source}/alertmanager, POST /api/v1/voice/inbound/{source}[/menu|/transcription], POST /api/v1/sms/inbound/{source} |
per-source authentication (see above), not platform RBAC |
GET /api/v1/ack/{token}, POST /api/v1/voice/gather/{token} |
signed tokens |
Requires a token but no session: /mcp (Bearer np_…, 401 with WWW-Authenticate: Bearer resource_metadata="/api/v1/whoami" otherwise).
A Caddy snippet that keeps the operational endpoints internal:
monitoring.example.net { @internal { path /metrics /api/v1/system/* /api/docs* /api/openapi.json /status/* not remote_ip 10.0.0.0/8 192.168.0.0/16 } respond @internal 403 reverse_proxy 10.10.10.11:8443}Do not block /healthz if the proxy (or CI) probes it; the deploy workflow probes http://localhost:8443/healthz on the VM directly.
Mutations, logins (login.local, login.ldap, setup.admin, user.register), token and secret operations, bundle applies, AI actions and federation applies are recorded with actor, tenant, source IP (TCP peer), request id and before/after snapshots in a SHA-256 hash chain. Verify with np audit verify; export NDJSON for long-term retention — the table itself is never purged. Not audited: reads, OIDC logins, failed logins, logouts. Full description: Observability → Audit log.
Known gaps — be aware
Section titled “Known gaps — be aware”These are real behaviours of this version, documented so you can compensate; they are tracked in Roadmap and known issues.
| Area | Gap | Mitigation |
|---|---|---|
| RBAC | Role scope.folder / scope.selector / scope.tenantId are stored and editable but not enforced — a role’s permissions apply to the whole tenant. |
Use separate tenants for isolation; do not rely on folder scopes. |
| RBAC | System roles (admin, operator, viewer, ai-agent) are marked immutable in the UI but can be modified or deleted via PUT/DELETE /api/v1/roles/{name} with admin:write. |
Restrict admin:write; watch role.update/role.delete audit entries. |
| Tenancy | GET /api/v1/users lists users of all tenants; e-mail addresses are globally unique. |
Grant admin:users only to instance administrators. |
| Tenancy | POST /api/v1/alerts/{id}:ack ignores X-Northplane-Tenant (uses the caller’s home tenant); :resolve/:snooze honour it. |
Cross-tenant operators ack from a token/user in the customer tenant. |
| Proxy | X-Forwarded-For is not used: audit sourceIp, token ipBind, the login rate limiter and site heartbeat sourceIp see the proxy address. |
Keep client-IP logs at the proxy; bind tokens to the proxy or omit ipBind. |
| Audit | No retention/purge; failed logins are not audited (OIDC logins and logouts are, as login.oidc/logout); on PostgreSQL chain verification can report false breaks (jsonb). audit:export streams without the 30 s request deadline. |
Export regularly and page with afterSeq. |
| Sessions | Password change or admin reset does not invalidate existing sessions; no IdP (RP-initiated) logout. | Disable the user to cut access immediately. |
| Setup | Default-admin seeding closes /setup on default installs; the generated password is printed to the logs once. |
Set NP_DEFAULT_ADMIN_PASSWORD or NP_DEFAULT_ADMIN_DISABLED=1 explicitly. |
| Ingest | Alertmanager receiver ignores enabled; ?token= accepted for token sources; ingest source names are global. |
Remove the source/secret to stop a receiver; use headers or HMAC. |
| Tokens | ipBind is not evaluated for northplaned mcp (stdio) — only expiry. |
Give MCP tokens narrow scopes and short expiry. |
| UI | The Admin page renders all 21 tabs regardless of permissions (non-admins see tabs whose calls return 403); the login/setup/register pages and the status page are German-only and unbranded. | Cosmetic; permissions are enforced server-side. |
| Status page | /status/default is public by default and cannot be configured or disabled through the API/UI. |
Block /status/* at the proxy if you do not want a public page. |
| Web push | Web push subscription flow is incomplete in the UI (server side is ready). | Use FCM/APNs through the alarm app (Mobile push). |