Skip to main content
Omniflow holds three things worth attacking: operator access to a panel that moves money, customer credentials that unlock a paid subscription, and the provider secrets that spend from funded balances. This page describes how each is protected, what the application deliberately refuses to do, and where the current build’s behaviour differs from what an operator might assume. Everything here is a property of the code rather than a recommendation. Where a control does not exist, it says so.

The three credential surfaces

The HTTP API has exactly three prefixes, and they never share middleware. Collapsing any two of these onto one prefix would mean one silently inherits the other’s middleware. Keeping them apart makes “a customer credential cannot reach an operator route” a property of the route table rather than a check somebody has to remember, and a test asserts it. admin_users is entirely separate from users. An administrator is never a VPN customer by virtue of holding a panel account, and creating one produces no Remnawave entitlement.
APP_OPERATOR_TOKEN is a third operator credential with no roles and no CSRF. Its mutations are audited under the actor operator-token, which names the token rather than a person. It is also a hard startup requirement for the API. Generate it from a cryptographic source, keep it out of the repository and out of chat, and rotate it by editing the environment and restarting. See Integrating with the API.

Operator passwords

Passwords are hashed with argon2id and stored as PHC-encoded strings that carry their own cost parameters. Recording the parameters inside the hash means raising the cost later needs no migration and no forced reset: existing hashes keep verifying under their own recorded costs, and a hash produced with weaker parameters is silently upgraded during the next successful sign-in. A rehash failure never blocks the sign-in itself. There are no composition rules. Length is counted in runes so a non-Latin passphrase is not wrongly rejected. These values are compiled in and are not environment-configurable. A malformed or corrupt hash fails closed: verification returns an error rather than a match, and the hash is treated as needing replacement rather than being trusted.

Sign-in protection

  • Enumeration resistance. An unknown address is verified against a randomly generated decoy hash built at service construction with the live parameters, so a missing account costs the same as a real one. An OIDC-only operator with no password set spends the same decoy verification.
  • One shared error. A wrong password, an unknown address, and a non-active account all answer 401 invalid_credentials. A locked account answers 429 account_locked.
  • Lockout. Five consecutive failures lock the account. The wait doubles on each further failure from a one-minute base and is capped at one hour, so a known operator cannot be locked out permanently. A locked account is not verified against at all, so guessing during a lockout cannot time the password check.
  • Rate limits. 10 attempts per minute per normalised email address and 30 per minute per client IP — one so a single account cannot be ground down, one so a spray across many accounts from a single origin is bounded too. The limit is applied before any hashing.
  • Successful sign-in resets the failure counter, clears the lock, and records last_login_at.
Email normalisation folds case and trims whitespace only. Dots and plus-tags are preserved, because some providers treat them as genuinely distinct mailboxes.

Changing and resetting a password

POST /v1/panel/auth/password re-proves the current password, then revokes every other session, invalidates any outstanding reset token, audits admin.password.changed, and queues an operator security notice. POST /v1/panel/auth/password-reset always answers 202 Accepted with no body, whether or not the address matched an account and including when the rate limit (5 per hour per IP) fires, so the endpoint cannot be used to discover which addresses are registered. It issues no token. For an active account it writes the audit row admin.password.reset_requested and posts a notice to the 🔐 Admin security topic — at most one per account per hour — so the owner learns that somebody asked. There is no channel in this build that reaches one operator and nobody else: no email transport, and the operator Telegram group is shared. A token is therefore issued only on the server, by the API binary, which prints it to the terminal that ran it and writes it nowhere else:
The command needs APP_DATABASE_URL and APP_DATA_ENCRYPTION_KEY, which the API container already has; anyone who can run it could read the database anyway, so it grants nothing new. It audits admin.password.reset_issued and posts a security notice. Hand the token to the operator over a channel you trust; they redeem it with POST /v1/panel/auth/password-reset/complete ({"token", "newPassword"}). The token lives one hour, is single-use, and issuing a new one retires the previous one. Completing a reset revokes all sessions — a reset is the remedy for a suspected compromise, so nothing established beforehand may survive it.
Earlier builds wrote the reset token to the API log at warn level, which let anyone who could read the logs take over any operator, the owner included, by requesting a reset first. If you ship logs anywhere, search them for admin password reset issued and treat any token found there as exposed — issuing a new one with the command above retires it. Neither the request nor the completion endpoint has a screen in /admin; both are API-only.

Two-factor authentication

TOTP follows RFC 6238: SHA-1, 6 digits, a 30-second step, and one step of drift accepted either side. It is implemented in the repository rather than taken as a dependency because the specification is small and fully specified, and the tests assert against the RFC’s own published vectors — stronger assurance for a security-critical path than an unaudited module. Enrolment is two steps. POST /v1/panel/auth/totp stores the secret immediately but leaves it unconfirmed, and an unconfirmed secret satisfies no login challenge, so an abandoned enrolment leaves the account exactly as it was. POST /v1/panel/auth/totp/confirm activates it and returns ten single-use recovery codes — the only time they are ever returned. Only their SHA-256 digests are stored, which is correct here because the codes have uniform entropy and there is no dictionary to defend against. Recovery codes are two groups of five from a 32-symbol alphabet without the ambiguous I L O U. Matching normalises case, spacing, and the hyphen, so retyping from paper works. Single use is a SQL property: a replayed code updates no row. Confirming a new enrolment deletes and re-issues the whole set. Disabling two-factor requires re-entering the password, because removing a second factor is exactly what an attacker on a hijacked session would attempt. It also deletes every recovery code and queues a security notice. Verification compares against every candidate time step in constant time and does not stop early, so timing does not reveal which step matched. The challenge endpoint is limited to 15 attempts per minute per client IP, and that is the least of the limits, because a caller can rotate addresses:
  • A code is accepted once. The account stores the time step of the last code it accepted (totp_last_used_step) and refuses that step or an earlier one, so a code read over a shoulder or out of a proxy log cannot be replayed inside its 90-second window. The code that confirmed enrolment is spent the same way.
  • A pending session gets five wrong codes. The fifth revokes it (revoked_reason = 'challenge_failed'), the endpoint answers 401 unauthenticated, and a new attempt needs the password again.
  • The account counts wrong codes across sessions (totp_failed_count) and applies the password lockout curve to them: five failures lock the account, doubling up to one hour. A correct password does not reset this counter — only a correct code or a recovery code does — so signing in again buys no fresh allowance. While locked, both the password step and the challenge answer 429 account_locked without verifying anything.
On a successful challenge the session token is rotated in the same statement that clears the pending state, so the cookie held during the challenge cannot be replayed as a fully authenticated one. APP_ADMIN_TOTP_ISSUER (default Omniflow) is the label shown in authenticator apps. Secrets are sealed with APP_DATA_ENCRYPTION_KEY.
Two-factor cannot be made mandatory in this build. It is per-account and voluntary. The panel’s settings screen offers a security section with requireTotp, sessionIdleMinutes, sessionAbsoluteHours, and allowedOrigins; those keys are stored but nothing in the API reads them, so setting them changes no behaviour. Enrolment has to be an operational expectation, checked from the operator list at /admin/operators, which shows each account’s two-factor state.

Passkeys

A passkey signs an operator in on its own — no password, and no second factor after it. That is not a shortcut: the authenticator proves possession of a private key and verifies the person holding it with a PIN or a biometric, so a single assertion already carries two factors. Demanding a TOTP code on top would be asking for a third. Registration requires both properties and refuses a credential that lacks either. residentKey: required makes the credential discoverable, which is what lets the sign-in screen offer it before anyone has typed an email address; a non-discoverable credential would work as a second factor and then silently fail to appear at sign-in. userVerification: required is checked twice — asked for in the request, and verified in the response before the row is written — so an authenticator with no PIN is rejected at registration rather than stored and rejected at every sign-in. Only the public key is stored. The private half never leaves the authenticator, so admin_passkeys is a table a database dump yields nothing useful from. Neither the public key nor the credential identifier is returned by the API: they are not secret, but a list screen has no use for them.

What binds a passkey, and what breaks it

A credential is bound to a relying-party identifier, derived from APP_PUBLIC_URL rather than configured separately so the two cannot disagree. This has a consequence worth planning around:
Changing the panel’s domain invalidates every registered passkey. The browser will not offer a credential to a site it was not created for, and there is no migration for this — it is what makes a passkey unphishable. Operators re-register after a domain change, signing in with their password first. An installation with no APP_PUBLIC_URL has no origin to bind to, so passkeys are switched off entirely and the sign-in screen omits the button rather than offering one that opens nothing.

Clone detection

Every assertion carries a signature counter. An authenticator that implements one increments it on each use, so a counter that fails to advance means two devices are answering for one credential — the signature of a copied key. That assertion is refused and the account is notified, because the person holding the original has no other way to learn a copy exists. Many authenticators — most platform ones — never implement a counter and report zero forever. That is legal, and treating it as a clone would lock out the most common hardware there is, so zero-to-zero is accepted and only a counter that went backwards is refused. The stored value advances with GREATEST, so a replayed assertion cannot roll it back to make the next replay look fresh.

The challenge, and the two halves of the API

The WebAuthn exchange is two requests, and the challenge issued by the first must reach the second unaltered. It travels in a sealed, HttpOnly, SameSite=Strict cookie with a five-minute lifetime, exactly as the OIDC flow state does — a challenge the browser could choose is not a challenge. It is cleared before the assertion is validated, so a failed attempt leaves nothing behind to replay. Sign-in is public by necessity: the assertion is what proves who the operator is, so POST /v1/panel/auth/passkey/login/begin and /finish run before any session exists. Key management is not: listing, registering, renaming, and revoking sit behind the session gate under /v1/panel/auth/passkeys, and every one is scoped to the owner in SQL, so another operator holding a valid session and a valid passkey identifier gets 404. A session started by a passkey records passkey in its authentication methods, which is what lets an audit reader tell one sign-in route from another.

The password stays

Passkeys are added alongside the password and its second factor, never in place of them. That is deliberate on both sides: it means the password remains the way back in when every key is lost, and therefore that revoking a passkey is never an account-recovery event — the last one can be removed without locking anybody out. Registering a key, renaming one, and revoking one each queue a security notice, because registering a way into the account is exactly what somebody on a stolen session would do. Operators manage their own keys at /admin/security.

Sessions

Both panels use the same primitive: a 256-bit random token given to the browser, and only its SHA-256 digest stored. A stored session must be useless to whoever reads the storage, so a database dump or a leaked backup never yields a cookie that authenticates anything. Two deadlines run at once, because each alone is insufficient. The idle window stops an unattended browser from being useful; the absolute window bounds a stolen cookie even when a background tab keeps the session warm. A request made just before the absolute horizon cannot push the idle deadline past it — the slide is clamped to the absolute deadline in SQL. The customer policy is deliberately more generous, and the code argues for it: the operator panel moves money and reads other people’s data, and signing in again is a few seconds of a working day. A customer checking how many days are left on their subscription is doing neither of those things, and a panel that logs them out over lunch simply pushes them back to the bot. The absolute horizon does the security work there instead of the idle one, backed by rotation and by re-authentication on the few actions that genuinely warrant it. Neither policy is environment-configurable. The defaults always apply.

Rotation and revocation

Rotation is invisible to handlers: the session middleware reissues the cookie centrally when the token rotates. Revocation is checked before expiry so the audit trail records the more specific reason when both apply, and a suspended operator or a suspended customer is refused at resolution rather than at each handler, so no surface can forget the check. The cookie is cleared on refusal so a browser stops presenting a dead session on every subsequent request. revoked_reason is a closed vocabulary. Operator sessions use logout, logout_all, password_change, admin_revoked, expired; customer sessions add session_revoked, identity_unlinked, provider_disabled, account_suspended, account_deleted. Both panels expose their live sessions at /admin/security and /account/security with the user agent, IP, and last-seen time, mark the current one, and allow ending any other. The two “sign out everywhere” controls differ on purpose: the operator one keeps the calling session, so an operator is not signed out of the browser they are using to secure the account; the customer one does not, because it exists for a suspected compromise and sparing the session that pressed it would leave an attacker in if they were the one holding it. Ending another operator’s session is not possible by guessing its identifier: revocation verifies ownership by listing the caller’s own sessions first.
Expired session rows are never purged. Both services carry a purge routine and documented retention windows — 30 days for sessions and magic links, 180 days for customer security events — but nothing calls either one. The retention worker sweeps bot sessions, webhook events, support attachments, outbox events, telemetry events, and drift records, and does not touch admin_sessions, customer_sessions, customer_magic_links, or customer_security_events. Those tables grow without bound. Expired sessions do not authenticate — validation is by deadline, not by row presence — so this is a storage and hygiene problem rather than an access one.

CSRF and cookies

SameSite=Lax blocks most cross-site posts, but it is a browser-side control only, so the server verifies its own token on every unsafe method. Strict was rejected deliberately: an OIDC callback arrives as a cross-site navigation and must land signed in. The CSRF token, not the cookie policy, is what actually defends the unsafe methods. The customer routes that open a session — POST /v1/account/auth/telegram, /auth/telegram/miniapp, and /auth/link — have no session token to check yet, so they are guarded against login CSRF by what the browser reports instead: the body must be application/json (a form cannot send it, and a cross-site fetch that sets it is preflighted and never approved), Sec-Fetch-Site must be same-origin or none, and a browser that sends no Sec-Fetch-Site must send an Origin whose host is the request’s host. Otherwise the answer is 403 cross_origin_request or 415 unsupported_media_type. The bot’s sign-in link opens no session on its GET; see the sign-in link.
  • GET, HEAD, and OPTIONS are exempt. Every other method must send X-CSRF-Token matching the session’s own token, or the request is 403 csrf_failed.
  • The token is base64url(HMAC-SHA256(csrf_secret, <label>)), derived from a secret held with the session. A token minted for one session cannot be replayed in another.
  • The labels differ between panels — omniflow.admin.csrf and omniflow.customer.csrf — so neither panel can mint a credential for the other.
  • Both panels publish the current token on the X-CSRF-Token response header of every authenticated request, and the shared browser transport captures it. A long-lived tab therefore never fails its next mutation after a session rotation.
Cookie flags on both session cookies: Path=/, HttpOnly, SameSite=Lax, Secure per configuration, and the __Host- prefix whenever Secure is set. The prefix is the important one — a browser refuses such a cookie if it carries a Domain attribute, so a sibling subdomain cannot set or overwrite an operator’s session. It is applied with Secure rather than independently of it, because the same rule that rejects a Domain attribute also rejects the cookie outright when Secure is absent. Naming a plain-HTTP cookie __Host- would not weaken it; it would stop the browser accepting it at all, so APP_ADMIN_COOKIE_SECURE=false and APP_CUSTOMER_COOKIE_SECURE=false would make sign-in impossible rather than merely less safe. A cookie without Secure is therefore omniflow_admin or omniflow_account, unprefixed and honest about what it guarantees. The two names differ so both panels can be open in one browser without either replacing the other.
Setting either to false is for a plain-HTTP local stack only, where a browser would otherwise refuse the cookie and sign-in could never complete. The API logs a warning at startup when it happens. Without Secure, a session cookie can be captured from any unencrypted request to the same host.
There is no CORS middleware anywhere. The browser transport is same-origin, and both panels must be served from the same origin that path-routes /v1/* to the Go API — see Reverse proxy.

Step-up authentication

Four customer actions require a sign-in within the last 15 minutes, or they answer 401 reauthentication_required:
  • unlinking a sign-in method
  • rotating a subscription access link
  • removing every device at once
  • requesting account deletion
These are the actions a stolen but still-live cookie could otherwise use to lock the real owner out of their own subscription. Each is destructive, none is reversible from the panel, and every one is something a customer does rarely and deliberately. Cancelling a deletion request needs no re-authentication, because cancelling only ever prevents the irreversible thing. The freshness reference is the session’s creation time, not its last activity: using activity would mean a session open all day counts as freshly authenticated. The requirement is computed on every request rather than stored, so the answer never goes stale. The refusal is 401 rather than 403 because the remedy is something the customer can actually do.

Secret sealing and the write-only credential design

Every credential Omniflow stores on an operator’s behalf is sealed with AES-GCM under APP_DATA_ENCRYPTION_KEY, a base64-encoded 32-byte AES key. Generate it with openssl rand -base64 32. A service given a key of any other length refuses to start. Each field is sealed under its own associated-data label, so a ciphertext moved between columns fails to open rather than decrypting somewhere it does not belong: Customer contact values are sealed with the same key. Encrypted database backups use a separate key, APP_BACKUP_ENCRYPTION_KEY, which must be stored outside the backup itself. The design rule above the encryption is that a credential is write-only. Nothing in the API ever returns one:
  • Installation settings return the document and a secretConfigured boolean. The secret column is not selected by the query, so returning the document is safe by construction rather than by a filter a refactor could drop.
  • OIDC providers return hasClientSecret.
  • Payment and goods provider settings return credentialsSet flags.
  • Blocklist sources never return the stored authorization header.
  • Saved payment methods return the provider’s masked label, never the token.
  • On every one of these, saving with an empty or null credential means keep what is stored, so a form can be re-saved without echoing a secret.
Rotating APP_DATA_ENCRYPTION_KEY makes every sealed value unopenable at once: TOTP secrets, OIDC client secrets, provider credentials, and customer contact values. There is no re-encryption path. Treat it as permanent for the life of the installation, and back it up separately from the database — a backup that contains both is a single artefact that decrypts itself.

Trusted proxies and client-IP resolution

The resolved client address feeds rate limiting, session records, audit rows, and customer security events. An X-Forwarded-For header is trivially forged, so it is honoured only when the immediate peer is a configured proxy. Set APP_TRUSTED_PROXIES to a comma-separated list of IP addresses or CIDR blocks. A bare address is accepted and treated as a single-host network. An unparseable entry is a startup error. With the variable empty, the header is ignored entirely and the socket address is used, which is the correct default for a directly exposed deployment. When the peer is trusted, the list is walked right to left and resolution stops at the first address that is not itself a trusted proxy — that is the closest hop there are still grounds to believe. Getting this wrong matters in both directions: trust nothing behind a proxy and every request shares one address, so one customer’s failed sign-ins exhaust the per-IP budget for everybody; trust too much and an attacker chooses their own address. A client whose address cannot be resolved at all shares the rate-limit key unknown. The customer panel is wired with the same list; there is no separate customer setting.

Rate limiting

Limits are Valkey-backed fixed-window counters keyed omniflow:rate:<scope>:<subject>. Contact addition is limited because the response distinguishes “accepted” from “not available”, which is enough to test addresses in bulk if it can be repeated freely. Personal-data export is limited because it is expensive to produce and maximally sensitive to hold. Limits fail closed: if Valkey is unreachable the request is denied and a warning is logged, because an outage must not silently remove the protection. The practical consequence is that a Valkey outage blocks operator sign-in entirely. There is no Retry-After header on any 429. Customer sign-in itself is not rate limited. The Telegram widget, the Mini App payload, the magic-link redemption, and both OIDC routes pass through no limiter. The Telegram paths are bounded by their signature checks and a one-minute freshness window instead, and magic links are bounded to five per account per hour at issue time.

Security headers

Every /v1/panel and /v1/account response carries a fixed, restrictive set. frame-ancestors 'none' and X-Frame-Options: DENY exist so the operator panel cannot be embedded and clickjacked, applied at the API layer as defence in depth. The customer set is the same one without X-Frame-Options, and its no-store and no-referrer carry a stronger reason there: a customer response can contain a subscription link, which is a credential. no-store keeps it out of shared caches and the browser’s back-forward cache, and no-referrer keeps a URL carrying one from being handed to whatever the customer navigates to next. The pages themselves are served by the web application, which sets its own Content-Security-Policy on every HTML response. Scripts run only with a nonce minted per request (script-src 'self' 'nonce-…' 'strict-dynamic'), plugins are refused (object-src 'none'), and the policy depends on the surface: Advertising counters are never loaded on an operator page, whatever is configured. Every response on every prefix also carries X-Request-ID. A panic recovery handler and a 60-second handler timeout apply process-wide, with ReadHeaderTimeout 5s, ReadTimeout 15s, WriteTimeout 30s, and IdleTimeout 90s on the server itself.

What is never logged or returned

The processes log JSON to stdout (stderr for the worker) with no HTTP access-logging middleware, so request paths, headers, and bodies do not reach the log by default. On top of that:
  • A redactor replaces subscription links of every scheme (https, vless, vmess, trojan, ss), Telegram bot tokens, bearer/basic/API-key/password patterns, 13–19 digit runs, and hardware and device identifiers with [redacted]. It is deliberately aggressive: an over-redacted log line is a minor inconvenience, a leaked subscription link or bot token is an incident. It is applied to the last error of a job before it leaves the process, and to every operator notification payload.
  • Operator security notices name the event and the account and nothing else. An address, a token, or a client IP would end up in a group chat; the audit trail already holds that detail behind the permissions that govern it. Only whitelisted fields are rendered, so anything else in a payload is ignored.
  • Readiness output is a classification, timeout or unavailable, never an underlying driver message, so /readyz cannot leak a connection string.
  • Metric labels are bounded values — a route template, a provider name, an outcome — never a concrete path with identifiers in it.
  • Webhook history never returns the raw provider body. It is the provider’s own payload and can contain payment details.
  • Fulfillment history returns status, correlation, and an error classification only, because the stored request and response summaries can carry a subscription link.
  • Subscription links are never persisted. There is no column for one; the link is fetched live from Remnawave per request.
  • Device lists never expose a hardware identifier. Both panels address a device by a digest and resolve it back server-side, buying one extra Remnawave call in exchange for the identifier never travelling.
  • The audit trail’s metadata is operator-facing context, never a secret, and the CSV export prefixes any field starting with = + - @ TAB CR with an apostrophe so a spreadsheet cannot evaluate an exported value as a formula.
The one place a secret is written to a log on purpose is the first-owner setup token, at warn level, because there is no other delivery channel before any operator exists and it stops being issued the moment the owner is created. Do not forward that log to a chat room or a ticket. The operator password-reset token is never logged; it is printed only by api admin reset-password.

The audit trail

Every authentication, authorization, configuration, customer, financial, support, marketing, system, and risk action appends a row to audit_events, carrying the actor, target, action, category, outcome, reason, request ID, timestamp, and bounded metadata. The API exposes reads only — nothing can modify a row, which is what makes the trail evidence rather than editable state. A state change and the audit row describing it are written in one transaction, so the trail can never disagree with what actually happened. A permission denial is recorded as well as refused: an operator repeatedly reaching for something they cannot do is exactly the signal an audit review looks for. There is no retention step for audit_events. Financial, audit, identity, webhook, and outbox records are append-only by design. Browse the trail at /admin/audit with audit.read; export the filtered view as CSV with audit.export, bounded at 10 000 rows. Full detail is in Admin panel.

Customers see their own security history

Customers read a separate, append-only log at /account/security: sign-ins, sign-outs, session revocations, magic-link requests, identity changes, subscription-link rotations, and device removals. It is a different table from audit_events because the audience is different — deciding per row which fields of an operator record a customer may see would put a disclosure decision in the read path. Its event vocabulary is closed and it has no column for free text, an amount, a link, or another party’s identifier. The IP is included on purpose: an address the customer does not recognise is the entire reason the screen exists. See Customer privacy for the personal-data export and the deletion request.

Operational expectations

These are the parts an operator has to get right; the application cannot enforce them.
  • Keep PostgreSQL and Valkey off the public network.
  • Serve both panels over TLS from a single origin that path-routes /v1/* to the API, and leave APP_ADMIN_COOKIE_SECURE and APP_CUSTOMER_COOKIE_SECURE at true.
  • Set APP_TRUSTED_PROXIES when the API sits behind a reverse proxy, and leave it empty when it does not.
  • Restrict /metrics at the reverse proxy; it is unauthenticated whenever APP_METRICS_ENABLED is on.
  • Store APP_DATA_ENCRYPTION_KEY and APP_BACKUP_ENCRYPTION_KEY outside the repository and outside the backups, and rotate APP_OPERATOR_TOKEN after any suspected exposure.
  • Enrol every operator in TOTP and check the state on /admin/operators — nothing requires it.
  • Grant the narrowest role that does the job. Only an owner may create an operator or change role grants, which is what stops a compromised administrator session minting itself a second account.
  • Run supported patch versions of Omniflow, Remnawave, PostgreSQL, Valkey, and the reverse proxy, and review automated dependency updates.

Reporting a vulnerability

Do not report security vulnerabilities through public issues, discussions, logs, or chat rooms. Use GitHub private vulnerability reporting on the repository. Include the affected versions, the impact, reproduction steps, and any suggested mitigation. Remove real customer data, tokens, payment payloads, and VPN access links from the report before sending it. Maintainers acknowledge complete reports as soon as practical, validate impact, coordinate a fix, and publish an advisory when users can act on it. Security fixes are provided for the latest stable release.

Admin panel

Roles, permissions, sessions, and the audit trail in detail.

OIDC sign-in

Adding an identity provider for operators or customers.

Integrating with the API

The bearer-token surface and what holding that token means.

Backup and restore

Encrypted backups and where the second key belongs.