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.
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" Wildcard tunnels only. Edge-auth applies to
*.21tunnel.com hosts. Custom domains are exempt
(the session cookie can't span an arbitrary apex) — gate
those at your origin instead. 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 or by rotating the key.
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.