Security
How marzban-mcp handles credentials, gates access by profile, and confirms every destructive action before it runs — in full detail.
This page covers every safety mechanism the server has, and why each one is shaped the way it is. If you're deciding which profile to run in, or reviewing this before pointing it at a production panel, start here.
Credentials
Credentials come from environment variables only, read once at startup, and are fixed for the process's entire lifetime. No tool's inputSchema accepts a field named token, password, credentials, base_url, url, or host — that's an architectural rule enforced across every tool, not a per-tool judgment call. A tool that accepted a URL or token as an argument would turn the server into an open proxy: text injected into ordinary data (a user's note field, say) could make it call out to an attacker-controlled host with attacker-controlled credentials.
Why both username and password are required
Marzban's session tokens are short-lived by default, and the panel has no separate mechanism for long-lived API keys for external systems — a token is purely a per-login session artifact. MARZBAN_USERNAME/MARZBAN_PASSWORD let the server silently re-authenticate whenever a token expires, which it will over a server process's lifetime of hours or days. MARZBAN_TOKEN is accepted too, but purely as a startup optimization — if it's still fresh, the server skips the first login call. It is never a substitute for the password: without one, every tool would start failing the moment the initial token expired, with no way to recover short of a restart.
Profiles
A profile is an access-control boundary, not a display hint. A tool outside the active profile is never registered — it doesn't appear in tools/list at all, so a model can't call what it can't see, and can't be talked into calling it either.
| Profile | Exposes |
|---|---|
readonly | Read-only tools only: lists, lookups, stats |
standard (default) | Read + write: full user CRUD, renewals, status changes, subscription lookups |
full | Everything in standard, plus every destructive tool |
Set with MARZBAN_MCP_PROFILE — see Configuration.
Confirming destructive actions
A destructive tool's first call never runs anything. It describes exactly what would happen — with real context pulled from the panel, like the target user's current status or a diff against the current config — and returns a one-time token instead of a result. Only a second call, with that token attached, actually executes:
> marzban_users_delete({ username: "alice" })
< This will permanently delete user "alice" (status: active, used 12.4 GB,
expires 2026-09-01) and their subscription link. This cannot be undone.
Do not call this tool again until the user has explicitly said yes. Once
they have, repeat the exact same call with confirmToken: "v1.eyJwIjp7..."
added. The token is only valid for this tool and these exact arguments,
and expires in 5 minutes.
> marzban_users_delete({ username: "alice", confirmToken: "v1.eyJwIjp7..." })
< { username: "alice", deleted: true }A token being available is not the same as the user's own consent. The text above is meant to be shown to the human and acted on only after they actually say yes — not treated as a formality the model can clear on its own judgment. Nothing about possessing a valid token authenticates who's asking; on stdio, that boundary is the process itself, not this token.
The confirm_token mechanics
The token is signed (HMAC), not just opaque — a model can't forge one, and re-using or repurposing one fails outright. On every verification, all of the following must hold, or the request is treated as unconfirmed and a fresh token is minted:
- Signature is valid — rules out a token the model invented or altered.
- Not expired — a 5-minute TTL, so a confirmation from an old, abandoned turn can't fire later.
- Bound to this exact tool — a token minted for
marzban_core_restartis rejected if replayed againstmarzban_users_delete. - Bound to these exact arguments — a token minted for
{ username: "alice" }is rejected if replayed with{ username: "bob" }, or withall: falsesilently swapped toall: true. - Single-use — once verified successfully, the same token can't be verified again.
The signing key lives only in the server process's memory — a restart invalidates every outstanding token, by design.
Confirmation frequency: off / auto / always
MARZBAN_MCP_CONFIRM controls how often the flow above happens:
| Mode | Behavior | Best for |
|---|---|---|
auto (default) | Confirm once per tool name and exact arguments; a repeat of that same call proceeds without re-asking, but only within a 5-minute window | Interactive use — a genuine retry (a timeout, a reconnect) isn't re-confirmed, and isn't re-executed either (see below), but nothing broader is ever inferred from it |
always | Confirm every single call, with no memory of what was already confirmed | Shared or interactive environments where even one accumulated trust is undesirable |
off | Skip confirmation entirely, from the very first call | Fully automated, unattended environments only — there is no safety net once this is set |
Trust in auto mode is granted per tool and per exact call arguments, not per tool name alone — confirming a delete for alice does not trust a delete for bob, and confirming marzban_users_reset_traffic { username: "alice" } does not trust the same tool called with { all: true }. Each grant also expires after the same 5-minute window a confirm token uses, so a tool whose arguments never vary (marzban_core_restart always takes {}) can't turn one confirmation into unlimited free re-runs for the rest of the connection. Every call that proceeds on accumulated trust — rather than a freshly verified token — is logged at info level (the default MARZBAN_MCP_LOG_LEVEL=warn suppresses it — set it to info or lower to see these).
Repeated calls and unknown outcomes
Confirmation decides whether a call may run. It doesn't decide whether a call has already run — and those come apart exactly when it matters. A client that times out waiting for a tool call re-issues it; without a second mechanism, that second call restarts the core again, or overwrites the config again, and nothing in the conversation reveals that it happened twice.
So every destructive call is remembered for 5 minutes, keyed by the tool name and the exact arguments (the confirmToken is deliberately left out of that key, so a retry carrying one still matches the original call). Three things can happen:
| Situation | What you get |
|---|---|
| First call of its kind | It runs normally |
| Identical repeat within 5 minutes | The recorded result of the first call, with a note saying so. Nothing is sent to the panel |
| The first call's outcome was never observed — an unsafe request that got no answer at all | An explicit unknown result telling the model to check the current state with a read-only tool instead of guessing or retrying |
The last row is the important one. When a request goes out and no response comes back, nobody can honestly say whether the panel applied it — a core restart is, after all, the request most likely to kill the connection carrying it. Saying "I don't know, go look" is the only safe answer: a model told "failed" retries, a model told "unknown" reads.
This applies in every MARZBAN_MCP_CONFIRM mode, because it protects the panel rather than the prompt — in off, it's the only protection left. A fresh confirmation still runs the operation for real: if a human is asked again and says yes again, that's a deliberate second run, not a retry. An accidental retry can never look like one, because it re-sends the already-consumed token, which fails verification.
The memory lives in the server process only. Restarting the server clears it, exactly like the confirm-token signing key — a fresh process makes no claims about what a previous one did. Note also that in auto mode a deliberate immediate re-run of a tool that takes no arguments (marzban_core_restart) isn't possible: wait out the 5-minute window, or run with MARZBAN_MCP_CONFIRM=always.
Credential masking
proxies, subscription_url, and links are functionally access credentials — a vless:// link embeds a UUID that grants connectivity — so they're masked by default in every tool response:
proxiesshows only the configured protocol names, not their settings.subscription_urlshows only its origin (https://panel.example.com/*** (hidden...)).linksshows only a count (3 link(s) (hidden...)).
Set MARZBAN_MCP_SHOW_LINKS=true to reveal them in full. This is a startup-time, all-or-nothing switch — there's no per-call override, so a model can't opt itself into revealing credentials mid-conversation.
No token or URL passthrough
Related to the credentials rule above but worth calling out on its own: nothing in this server accepts a token, panel URL, or hostname from a model and uses it to make a request. The one narrow, intentional exception is marzban_subscription_info, which takes a subscription token (the low-privilege, per-user token embedded in a subscription link — not an admin credential) and queries the same public, unauthenticated endpoint a user's own client apps already hit, against the one fixed MARZBAN_BASE_URL configured at startup. It never accepts an arbitrary URL.
Config-write safety net
marzban_config_update and marzban_hosts_update are the two tools that overwrite panel-wide configuration wholesale, so they carry extra guardrails beyond confirmation:
- Structural validation —
marzban_config_updaterefuses a payload that doesn't have arrayinbounds/outboundsfields, before it's even offered for confirmation. - Automatic backup — both tools fetch and return the pre-write state as
backupin their response, so a mistaken write can be manually reverted. dryRunpreview —marzban_config_updateacceptsdryRun: true, which returns the exact diff against the current config with no write, no core restart, and — since it provably changes nothing — no confirmation step either.
Transport hygiene
All server logs go to stderr, never stdout — stdout is reserved exclusively for the JSON-RPC protocol, and a single stray log line there would corrupt the connection. Errors surfaced back to the model pass through a narrow, MCP-side text redactor (bearer headers, raw JWTs, and password/token/access_token/secret JSON fields), so a failure that happens to embed one of these never leaks a raw credential into the model's context either. This is a defense-in-depth scrub of already-flattened error text, not a full port of marzban-sdk's own object-level redaction (which only covers structured .details, not arbitrary rendered text).
What's never exposed
adminToken (issuing new admin JWTs) and admin account management (createAdmin/modifyAdmin/removeAdmin) are not registered as tools, in any profile. The server can manage what an admin manages — it can't mint new admin credentials for itself or anyone else.