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.
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-
activeaccount all answer401 invalid_credentials. A locked account answers429 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.
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:
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.
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 answers401 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 answer429 account_lockedwithout verifying anything.
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 fromAPP_PUBLIC_URL rather than configured separately so the two cannot disagree.
This has a consequence worth planning around:
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 withGREATEST, 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.
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, andOPTIONSare exempt. Every other method must sendX-CSRF-Tokenmatching the session’s own token, or the request is403 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.csrfandomniflow.customer.csrf— so neither panel can mint a credential for the other. - Both panels publish the current token on the
X-CSRF-Tokenresponse 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.
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.
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 answer401 reauthentication_required:
- unlinking a sign-in method
- rotating a subscription access link
- removing every device at once
- requesting account deletion
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 underAPP_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
secretConfiguredboolean. 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
credentialsSetflags. - 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.
Trusted proxies and client-IP resolution
The resolved client address feeds rate limiting, session records, audit rows, and customer security events. AnX-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 keyedomniflow: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,
timeoutorunavailable, never an underlying driver message, so/readyzcannot 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 CRwith an apostrophe so a spreadsheet cannot evaluate an exported value as a formula.
api admin reset-password.
The audit trail
Every authentication, authorization, configuration, customer, financial, support, marketing, system, and risk action appends a row toaudit_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 leaveAPP_ADMIN_COOKIE_SECUREandAPP_CUSTOMER_COOKIE_SECUREattrue. - Set
APP_TRUSTED_PROXIESwhen the API sits behind a reverse proxy, and leave it empty when it does not. - Restrict
/metricsat the reverse proxy; it is unauthenticated wheneverAPP_METRICS_ENABLEDis on. - Store
APP_DATA_ENCRYPTION_KEYandAPP_BACKUP_ENCRYPTION_KEYoutside the repository and outside the backups, and rotateAPP_OPERATOR_TOKENafter 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
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.