Four independent controls: a password on a single tunnel,
a Google sign-in gate with an allowlist, two-factor on your
own account, and a hard spend cap on any API key. Mix as
needed — they don't depend on each other. The last section
covers which key the mytunnel agent should use,
depending on where it runs.
HTTP Basic Auth on a single tunnel. Enforced at the edge before any request reaches your laptop — unauthenticated traffic never costs you bandwidth. The password is argon2id-hashed; we never store it in the clear.
curl -X PUT "$API/tunnels/$TID/auth" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{ "user": "demo", "password": "a-strong-passphrase" }' { "id": "3a1b2c3d-...", "auth_enabled": true } user is 1–64 chars; password is at
least 8. Takes effect on the next request — the edge gate
flips synchronously, no reconnect needed.
$ curl -i https://myapp.21tunnel.com/
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="myapp"
$ curl -i -u demo:a-strong-passphrase https://myapp.21tunnel.com/
HTTP/1.1 200 OK
... curl -X DELETE "$API/tunnels/$TID/auth" \
-H "Authorization: Bearer $JWT" { "id": "3a1b2c3d-...", "auth_enabled": false }
Plan note: setting a password requires Pro (or an active
trial). Free post-trial returns
402 upgrade_required. An existing password keeps
working after a downgrade — we don't proactively strip gates.
Put a tunnel behind Google OAuth with an email/domain allowlist. Visitors get bounced to Google before they ever reach your service; only allowed identities get a session cookie. Good for internal staging tools you want your team — and only your team — to reach.
curl -X PUT "$API/tunnels/$TID/edge-auth" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"provider": "google",
"allowed_domains": ["acme.com"],
"allowed_emails": []
}' { "id": "3a1b2c3d-...", "edge_auth_enabled": true } Anyone with an @acme.com Google identity gets
in. Add individual addresses to allowed_emails for
contractors on other domains.
allowed_domains entry → allowed.Up to 100 entries each. The allowlist is re-checked on every request, so revoking access is immediate — remove the entry and the next request from that user is blocked even if they still hold a session cookie.
curl -X DELETE "$API/tunnels/$TID/edge-auth" \
-H "Authorization: Bearer $JWT" Custom domains are gated too. A visitor on a
custom domain bound to the tunnel signs in the same way; the
domain then gets its own session cookie, valid on that host
only. Only google is supported today; other
providers return unsupported_provider.
Protect your dashboard account with a TOTP authenticator (Google Authenticator, 1Password, Authy, etc.). Enrollment is a two-step round-trip so a secret is never persisted until you prove you loaded it. Ten one-time recovery codes are issued at the end.
curl -X POST "$API/auth/mfa/enroll" \
-H "Authorization: Bearer $JWT" {
"secret_base32": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/21tunnel:you@acme.com?secret=...&issuer=21tunnel"
} Render otpauth_uri as a QR code (the dashboard
does this for you) or type secret_base32 into your
authenticator. Nothing is saved server-side yet.
curl -X POST "$API/auth/mfa/enroll/verify" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{ "secret_base32": "JBSWY3DPEHPK3PXP", "code": "123456" }' {
"status": "enabled",
"recovery_codes": [
"k7f2-9a3d", "m1x8-44bc", "...8 more..."
]
} Save the recovery codes now — they're shown once, hashed before storage, and are your only way back in if you lose the authenticator. The TOTP secret is encrypted at rest with the server master key.
Once enabled, login becomes two calls:
# 1. Password — returns a challenge instead of a JWT
curl -X POST "$API/auth/login" \
-d '{ "email": "you@acme.com", "password": "..." }'
# → { "mfa_required": true, "challenge_token": "..." }
# 2. Exchange the challenge + a code for the JWT
curl -X POST "$API/auth/login/mfa" \
-d '{ "challenge_token": "...", "code": "123456" }'
# → { "access_token": "eyJhbG..." } + refresh cookie code accepts a 6-digit TOTP or one of your
recovery codes. A ±1 step (30 s) tolerance covers clock
drift. OAuth (Google / GitHub) sign-in does not
bypass MFA — if you've enabled it, you complete it regardless
of how you started the login.
curl -X POST "$API/auth/mfa/disable" \
-H "Authorization: Bearer $JWT" Put a hard monthly spend ceiling on any API key. The most important control when you hand a key to an automated agent — the key holder cannot raise its own cap. Only an org owner can, via this endpoint. Rotating a key carries its cap over to the new key unchanged.
curl -X PUT "$API/tokens/$NONCE/budget" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{ "monthlyBudgetUsdCents": 5000 }' 5000 cents = $50/month. Valid range
0 … 1,000,000,000 (up to $10M/mo) or
null.
# Remove the cap (unlimited)
curl -X PUT "$API/tokens/$NONCE/budget" \
-H "Authorization: Bearer $JWT" \
-d '{ "monthlyBudgetUsdCents": null }'
# Freeze all spend — incident response
curl -X PUT "$API/tokens/$NONCE/budget" \
-H "Authorization: Bearer $JWT" \
-d '{ "monthlyBudgetUsdCents": 0 }' $NONCE is the first 16 hex chars of the token's
nonce, visible in the GET /tokens list.
Enforcement is on the agent transport at tunnel-registration
time — an over-budget agent's mytunnel register
is rejected with a BUDGET_EXCEEDED reason. See
the agent guide for how an
autonomous agent should handle that.
The mytunnel agent needs a key to open tunnels.
Which key, and where it lives, depends on where the agent
runs.
Your laptopmytunnel login — browser sign-in. Saves a 90-day key to the mytunnel config dir; no flags after that.CI, scripts, AI agentsA project key (mtk_…) in the env var MYTUNNEL_API_KEY. Read automatically, no flag needed. Mint one with mytunnel projects keys mint <project> --output-env (prints export MYTUNNEL_API_KEY=…) or from the dashboard.Servers, permanent tunnelsThe key in a root-only file, used via --token-file <path> or token_file = "<path>" in mytunnel.toml. mytunnel service install sets this up. Long-running tunnels re-read the file on every reconnect. --token-env NAME is only for a key that lives in
an env var with a different name; it takes the same
mtk_… key.
When more than one is set, the first match wins:
--token-file > --token-env > MYTUNNEL_API_KEY > saved mytunnel login credentials mtk_…) — opens
tunnels. Scoped to one project, with the quota, bandwidth
and budget set when it was minted.mtk_master_…) —
from the dashboard's API keys page, or
POST /api/tokens/master (admin or owner). Manages
projects and keys: mytunnel projects … and the
/api/projects/* routes. It cannot open
tunnels; the agent refuses it.
Put a master key in MYTUNNEL_API_KEY only in the
shell that runs mytunnel projects …. If that same
shell later exports a project key over it (for example with
eval $(mytunnel projects keys mint … --output-env)),
the next projects call sends the project key and
gets a 401.
A longer request is capped to the maximum, not rejected. Read
expiresAt from the mint response (the CLI prints
it as expires_at) to see what you actually got.
# Revoke one key; live tunnels on it are cut off at once
mytunnel projects keys revoke <project> <nonce>
# Swap a server's key for an equivalent one, in place
mytunnel projects keys rotate <project> --key-file /etc/21tunnel/agent.key
Both need a master key. A rotated key keeps working for a
grace window (--grace, default 1 hour), and an
agent reading the key file switches on its next reconnect —
no restart. Revoking a master key in the dashboard also
revokes every key it minted. Full walkthrough, including a
daily rotation timer:
rotate the key without a restart.