Block a path or an IP range. Inject a header. Cap requests per minute. Validate a JWT. Screen for attacks with Shield. Per-tunnel rules, applied before traffic ever touches your local service — denied requests don't burn your bandwidth, injected headers reach the upstream exactly like a regular client would set them.
A policy is JSON. It lives on the tunnel row in our database
and applies the next time a public request lands. The hot
path runs deny first (denied paths cost nothing),
then rate_limit, then header_set. Seven
more actions — IP allow/deny, method and content-type allow-lists,
body-size limits, JWT validation, and the Shield WAF — run in the
same edge pipeline (see below).
{
"actions": [
{ "kind": "deny", "path_prefix": "/admin" },
{ "kind": "rate_limit", "requests_per_minute": 60 },
{ "kind": "header_set", "name": "X-Forwarded-For",
"value": "203.0.113.42" }
]
} At the public HTTP edge — before the request opens an agent stream. That means:
Reject any request whose URI path starts with
path_prefix. Returns 403 with a fixed body.
Useful for putting /admin, /internal,
or /.git behind your firewall while still
exposing the rest of the app.
curl -X PUT -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"actions":[{"kind":"deny","path_prefix":"/admin"}]}' \
https://login.21tunnel.com/api/tunnels/$TID/policy $ curl -i https://myapp.21tunnel.com/admin/users
HTTP/1.1 403 Forbidden
Content-Type: text/plain
forbidden by traffic policy Prefix matching, not glob. /admin denies /admin,
/admin/users, and /administrator.
If you want stricter, use a more specific prefix
(/admin/ with the trailing slash).
A path you explicitly denied is not "legitimate traffic" —
it's noise. Counting it against the per-minute budget would
mean an attacker hammering /admin could
exhaust the budget and rate-limit your real users. The
evaluator short-circuits deny before
rate_limit so this can't happen.
Add (or overwrite) a header on the request forwarded to your local service. Inject API keys, mark traffic as edge-trusted, or stamp a tenant tag without modifying your backend.
curl -X PUT -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"actions":[
{"kind":"header_set","name":"X-Edge-Auth","value":"shh-its-me"}
]}' \
https://login.21tunnel.com/api/tunnels/$TID/policy GET /api/whatever HTTP/1.1
Host: myapp.21tunnel.com
X-Edge-Auth: shh-its-me
... rest of headers ...
The header is overwritten, not appended —
if the public client tries to set X-Edge-Auth
themselves, the policy's value wins. That prevents the
"smuggled header" footgun where the upstream sees two
values and picks unpredictably.
- + _, max 64 chars.header_set for the same name acts as the final write.
Fixed 60-second window. Once requests_per_minute
legitimate requests have been served in the current window,
further requests get 429 with
Retry-After: 60 until the window rolls.
At most one rate_limit action per policy.
curl -X PUT -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"actions":[{"kind":"rate_limit","requests_per_minute":60}]}' \
https://login.21tunnel.com/api/tunnels/$TID/policy HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain
rate limit exceeded by traffic policy The bucket is keyed on tunnel ID. 60 / min means 60 requests across all public clients hitting that tunnel — not 60 per source IP. For per-IP rate-limiting inside your app, your own framework still has to do that work; the edge policy is for catching runaway scripts and obvious abuse.
The window resets at the 60-second boundary. That means a burst of 2× the cap is possible right at the rollover (last request of window N + first of N+1 in < 1 ms). We chose fixed-window because:
requests_per_minute capped at 60,000 (≈ 1,000 req/s).
Up to 16 actions per policy, evaluated in the order
deny → rate_limit →
header_set. Mix to taste.
{
"actions": [
{ "kind": "deny", "path_prefix": "/admin" },
{ "kind": "deny", "path_prefix": "/.git" },
{ "kind": "deny", "path_prefix": "/.env" },
{ "kind": "rate_limit", "requests_per_minute": 120 },
{ "kind": "header_set", "name": "X-Tunnel-Source",
"value": "21tunnel-edge" }
]
} That policy:
/admin, /.git, /.env — common bot-probe paths.X-Tunnel-Source: 21tunnel-edge so your local logs can identify proxied traffic.
Beyond deny / rate_limit / header_set, the same policy pipeline
ships seven more actions. All are per-tunnel, evaluated at the
edge before any traffic reaches your service, and set the same
way — one more entry in the actions array.
ip_allowOnly these CIDRs may reach the tunnel — {"kind":"ip_allow","cidrs":["203.0.113.0/24"]}ip_denyBlock these CIDRs — {"kind":"ip_deny","cidrs":["198.51.100.7/32"]}method_allowRestrict HTTP methods — {"kind":"method_allow","methods":["GET","POST"]}content_type_allowOnly accept these request content types — {"kind":"content_type_allow","types":["application/json"]}body_size_limitReject oversized request bodies — {"kind":"body_size_limit","max_bytes":1048576}jwt_validationRequire a valid signed JWT (see below).wafScreen requests through Shield (see below) — {"kind":"waf","mode":"block","profile":"standard"}
Requests without a valid Authorization: Bearer token get
a 401 from us; your service never sees them. Optional issuer and
audience allow-lists narrow it further. Expiry is always enforced.
Point it at your issuer's JWKS endpoint and key rotation takes care of itself — this is what you want with Auth0, Cognito, Firebase, or anything else that publishes a JWKS:
{
"actions": [
{ "kind": "jwt_validation",
"algorithm": "RS256",
"jwks_url": "https://your-tenant.auth0.com/.well-known/jwks.json",
"allowed_issuers": ["https://your-tenant.auth0.com/"],
"allowed_audiences": ["my-api"] }
]
}
Keys are fetched in the background and cached, never during your
request. If your issuer is briefly unreachable the cached keys keep
working for a grace period; once that expires requests are
rejected rather than let through unverified. Add
"jwks_cache_ttl_secs" (60–86400) to control how
long keys are trusted before a refresh — a rotation is picked
up within about half a minute of that expiring, and sooner if a token
arrives signed by a key we have not seen.
A shared secret works too, and so does a pasted public key — though with the latter you have to paste it again on every rotation:
{ "kind": "jwt_validation",
"algorithm": "HS256",
"key": "your-shared-secret" }
Set exactly one of key or jwks_url.
jwks_url must be https and requires
"algorithm": "RS256" — a JWKS publishes public
keys, so there is nothing for HS256 to fetch.
Add a waf action and every request is screened by
Shield before it reaches your service. A built-in
signature engine (path traversal, SQL injection, XSS, command
injection, scanner and Log4Shell probes) runs on every deployment;
the hosted platform backs it with an OWASP Core Rule Set engine.
mode: "monitor"Log hits to the dashboard, don't block — safe to roll out.mode: "block"Return 403 on a hit and record the event.profile: "standard"Balanced rule set (default).profile: "strict"Broader coverage, more false positives.
Blocked and monitored hits appear on the dashboard's
Security page with the rule that matched. Start in
monitor, watch the events, then flip to
block.
Three endpoints. Authenticate with your dashboard JWT or an API key on the owning org.
GET /tunnels/:id/policy Read the current policy (or null). PUT /tunnels/:id/policy Set or replace. Body is the policy JSON above. DELETE /tunnels/:id/policy Drop the policy. Idempotent. curl -H "Authorization: Bearer $JWT" \
https://login.21tunnel.com/api/tunnels/$TID/policy {
"id": "3a1b2c3d-...",
"policy": {
"actions": [
{ "kind": "deny", "path_prefix": "/admin" }
]
}
} Validation runs before persistence. Common errors:
HTTP/1.1 400 Bad Request
{ "error": "bad_policy",
"message": "action[0] header_set: value must not contain CR or LF" } Other rejected inputs: too many actions (max 16), bad
rate-limit values, missing leading / on
path_prefix, second rate_limit
action.
Policies don't share across tunnels (yet). If you have
five staging tunnels all needing the same rules, you
PUT the policy on each one. Per-org reusable
policies are a roadmap item — file a request.
TCP and UDP tunnels are byte-pipes; there's no "path" or "header" to act on. Policies on a TCP tunnel return 200 + are stored but never fire.
Rate-limit buckets live in the tunnel-server process. On restart (rare), the window resets. The DB still holds the policy itself, so re-registration carries it forward.
Per policy. Bounds the worst-case evaluation cost on the hot path. Real policies are typically 1–3 actions — 16 is generous headroom.