> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> The whole security posture: the three credential surfaces, password, TOTP and passkey handling, session storage and rotation, CSRF, secret sealing, trusted proxies, headers, and what is never logged

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.

| Prefix | Credential | Gates | Scope of a compromise |
| - | - | - | - |
| `/v1/panel` | `__Host-omniflow_admin` session cookie (`omniflow_admin` without TLS) | security headers → session → CSRF → an RBAC permission per route | Whatever the operator's roles allow; every action is audited |
| `/v1/admin` | `Authorization: Bearer <APP_OPERATOR_TOKEN>` | the token, and nothing else | Every endpoint on the prefix, including refunds and wallet adjustments; each mutation is audited as `operator-token`, with no person behind it |
| `/v1/account` | `__Host-omniflow_account` session cookie (`omniflow_account` without TLS) | security headers → session → CSRF, plus recent-authentication on four routes | One customer's own account |

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.

> [!WARNING]
>
> `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](https://omniflow-vpn.mintlify.site/integrations/api.md).

## 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.

| Parameter | Value |
| - | - |
| Algorithm | argon2id |
| Memory | 64 MiB |
| Iterations | 3 |
| Parallelism | 2 |
| Salt | 16 bytes |
| Key | 32 bytes |
| Minimum length | 12 characters, counted in runes |
| Maximum length | 256 characters |

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:

```bash theme={null}
docker compose exec api /app admin reset-password owner@example.com
```

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.

> [!WARNING]
>
> 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`.

> [!NOTE]
>
> **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:

> [!WARNING]
>
> **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.

| Property | Operator | Customer |
| - | - | - |
| Cookie | `__Host-omniflow_admin` | `__Host-omniflow_account` |
| Cookie without `Secure` | `omniflow_admin` | `omniflow_account` |
| Idle timeout | 30 minutes | 14 days |
| Absolute timeout | 12 hours | 60 days |
| Token rotation | every 15 minutes | every 24 hours |
| Pending-challenge window | 5 minutes | — |
| Re-authentication window | — | 15 minutes |
| Cookie `Expires` | the idle deadline | the absolute deadline |

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.

> [!WARNING]
>
> **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](https://omniflow-vpn.mintlify.site/customer/web-panel.md#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.

| Variable | Default | Purpose |
| - | - | - |
| `APP_ADMIN_COOKIE_SECURE` | `true` | Marks the operator session cookie `Secure` |
| `APP_CUSTOMER_COOKIE_SECURE` | `true` | Marks the customer session cookie `Secure` |

> [!WARNING]
>
> 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](https://omniflow-vpn.mintlify.site/operations/reverse-proxy.md).

## 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:

| Label | Protects |
| - | - |
| `admin.totp` | Operator TOTP secrets |
| `admin.oidc.client_secret` | Operator OIDC client secrets |
| `admin.flow` | The operator OIDC sign-in flow cookie |
| `customer.oidc.client_secret` | Customer OIDC client secrets |
| `customer.flow` | The customer OIDC sign-in flow cookie |
| `panel.settings_section` | Secrets inside an installation settings section |
| `panel.ai_provider` | AI provider API keys |
| `panel.mcp_server` | MCP server credentials |

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.

> [!WARNING]
>
> 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>`.

| Scope | Subject | Limit | Window |
| - | - | - | - |
| `admin-login-account` | normalised email | 10 | 1 minute |
| `admin-login-ip` | client IP | 30 | 1 minute |
| `admin-challenge` | client IP | 15 | 1 minute |
| `admin-reset` | client IP | 5 | 1 hour |
| `promo` | customer ID | 10 | 1 minute |
| `account_contact` | customer ID | 10 | 1 hour |
| `account_export` | customer ID | 3 | 1 hour |

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.

| Header | Value |
| - | - |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `no-referrer` |
| `Cross-Origin-Opener-Policy` | `same-origin` |
| `Cross-Origin-Resource-Policy` | `same-origin` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), interest-cohort=()` |
| `Content-Security-Policy` | `default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'` |
| `Cache-Control` | `no-store` |
| `X-Frame-Options` | `DENY` — operator responses only |

`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:

| Surface | Third-party origins allowed | Framing |
| - | - | - |
| Operator panel (`/admin`) | none | `frame-ancestors 'none'` |
| Public and customer pages | the Yandex Metrica and Google Analytics hosts, used only when you [configure a counter](https://omniflow-vpn.mintlify.site/operations/advertising-measurement.md) | Telegram Web (`https://web.telegram.org`), which opens a Mini App in a frame |
| Customer sign-in (`/account/sign-in`) | the above, plus the Telegram login widget (`telegram.org`, `oauth.telegram.org`) and `'unsafe-eval'`, which the widget needs to run its callback | as above |

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](https://omniflow-vpn.mintlify.site/operations/admin-panel.md).

## 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](https://omniflow-vpn.mintlify.site/customer/privacy.md) 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.

## Related pages

> **[Admin panel](https://omniflow-vpn.mintlify.site/operations/admin-panel.md)**
>
> Roles, permissions, sessions, and the audit trail in detail.

> **[OIDC sign-in](https://omniflow-vpn.mintlify.site/integrations/oidc.md)**
>
> Adding an identity provider for operators or customers.

> **[Integrating with the API](https://omniflow-vpn.mintlify.site/integrations/api.md)**
>
> The bearer-token surface and what holding that token means.

> **[Backup and restore](https://omniflow-vpn.mintlify.site/operations/backup-restore.md)**
>
> Encrypted backups and where the second key belongs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.