Skip to content

Protection rules

Your own policy on top of the defense posture: each rule matches requests and decides what happens to them. The allowlist names what mitigation must never block. Their place in the request path: pipeline. Requests need API access.

Endpoints

Method Path Key Purpose
PATCH /services/{fqdn} write Change a vService

PATCH /services/{fqdn}

Body: ServicePatch
Returns: 200, Service

Request: waf_and_limit
{
  "waf": {
    "enabled": true
  },
  "rate_limiting": {
    "req_per_10s": 800
  }
}
Request: replace_origins
{
  "origins": [
    {
      "name": "origin-1",
      "address": "192.0.2.10",
      "port": 443
    }
  ]
}
Answer 200: waf_and_limit
{
  "fqdn": "a.example.com",
  "tenant": "tenant-a",
  "enabled": true,
  "mode": "https",
  "ports": [
    443
  ],
  "udp_ports": [],
  "created": "2026-10-01T00:00:00+00:00",
  "created_by": "",
  "account_id": "acc-a",
  "network": {
    "vips": [
      {
        "anycast_ipv4": "203.0.113.1",
        "anycast_ipv6": "2001:db8:a::1"
      }
    ],
    "pool": null,
    "edge_hostname": "a-example-com.edge.protect.zeroservices.eu"
  },
  "network_protection": {
    "enabled": true
  },
  "backend": {
    "algorithm": "roundrobin",
    "health_check": {}
  },
  "origins": [
    {
      "name": "origin-1",
      "address": "192.0.2.10",
      "port": 443,
      "tls": true,
      "ssl_verify": true,
      "weight": 100,
      "maxconn": 100
    }
  ],
  "waf": {
    "enabled": true,
    "fail_open": true,
    "mode": "On",
    "paranoia_level": 1,
    "inbound_threshold": 5,
    "disabled_rules": []
  },
  "rate_limiting": {
    "challenge_req_per_10s": 200,
    "req_per_10s": 800,
    "conn_per_10s": 50,
    "conn_drop_per_10s": 500,
    "path_default": 1000,
    "path_aggregate_per_10s": 5000,
    "geo_tcp_reject_sess_rate": 200,
    "ja4_enabled": false,
    "ja4_req_per_10s": 100,
    "ja4_action": "challenge"
  },
  "pow": {
    "enabled": true,
    "difficulty": 18,
    "cookie_ttl": 86400,
    "always_challenge": false,
    "exempt_crawlers": true,
    "bind_ip": true,
    "botcheck_per_10s": 1000
  },
  "admission": {
    "enabled": false,
    "credit_required": 5,
    "credit_window": "60s",
    "known_ttl": "24h",
    "unknown_conn_rate": 1500,
    "table_size": "1m"
  },
  "ssl": {
    "mode": "acme",
    "acme_provider": "zerossl",
    "acme_email": null,
    "https_redirect": false,
    "cert_status": "none",
    "cert_issued": null,
    "cert_expires": null,
    "cert_last_renewal": null,
    "days_remaining": null
  },
  "aliases": [
    {
      "fqdn": "www.a.example.com",
      "ssl": {
        "mode": "acme",
        "acme_provider": "zerossl",
        "acme_email": null,
        "https_redirect": false,
        "cert_status": "none",
        "cert_issued": null,
        "cert_expires": null,
        "cert_last_renewal": null,
        "days_remaining": null
      }
    }
  ],
  "cache": {
    "enabled": true
  },
  "slow_http": {
    "enabled": true,
    "timeout_http_request": "5s",
    "timeout_client_fin": "5s"
  },
  "tcp_timeout": {
    "client": "30s",
    "server": "30s",
    "tunnel": "30s"
  },
  "udp_timeout": {
    "idle": "300s",
    "unreplied": "30s",
    "unreplied_pkts": 32,
    "src_pps": 0
  },
  "sni_policy": "allowlist",
  "proxy_protocol": false,
  "tls_termination": false,
  "priority": {
    "enabled": true,
    "session_signal": []
  },
  "response_headers": {
    "hsts": {},
    "cors": {},
    "x_frame_options": null,
    "x_content_type_options": false,
    "referrer_policy": null
  },
  "content_negotiation": {
    "enabled": false,
    "encodings": [
      "zstd",
      "gzip"
    ],
    "file_types": [
      "css",
      "js",
      "html"
    ]
  },
  "logging": {
    "enabled": false
  },
  "threat_intel": {
    "mode": "log",
    "abuse": "log",
    "tor": "log"
  },
  "redirects": [],
  "origin_host": null,
  "origin_sni": null,
  "maintenance_mode": false,
  "auto_mitigation": false,
  "defense_posture": null,
  "protection_allowlist": null,
  "edges": [],
  "protection_rules": [],
  "append_trailing_slash": false,
  "exceed_no_rtbh": false,
  "exceed_no_flowspec": false,
  "origin_kind": "dynamic",
  "alias_limit": 20,
  "serving_edges": [
    "shared-1"
  ],
  "posture_drift": [],
  "baseline": null
}
Answer 200: replace_origins
{
  "fqdn": "a.example.com",
  "tenant": "tenant-a",
  "enabled": true,
  "mode": "https",
  "ports": [
    443
  ],
  "udp_ports": [],
  "created": "2026-10-01T00:00:00+00:00",
  "created_by": "",
  "account_id": "acc-a",
  "network": {
    "vips": [
      {
        "anycast_ipv4": "203.0.113.1",
        "anycast_ipv6": "2001:db8:a::1"
      }
    ],
    "pool": null,
    "edge_hostname": "a-example-com.edge.protect.zeroservices.eu"
  },
  "network_protection": {
    "enabled": true
  },
  "backend": {
    "algorithm": "roundrobin",
    "health_check": {
      "method": "GET",
      "path": "/",
      "host": null,
      "interval": "10s",
      "rise": 2,
      "fall": 3,
      "expect": null
    }
  },
  "origins": [
    {
      "name": "origin-1",
      "address": "192.0.2.10",
      "port": 443,
      "tls": true,
      "ssl_verify": true,
      "weight": 100,
      "maxconn": 100
    }
  ],
  "waf": {
    "enabled": true,
    "fail_open": true,
    "mode": "On",
    "paranoia_level": 1,
    "inbound_threshold": 5,
    "disabled_rules": []
  },
  "rate_limiting": {
    "challenge_req_per_10s": 200,
    "req_per_10s": 800,
    "conn_per_10s": 50,
    "conn_drop_per_10s": 500,
    "path_default": 1000,
    "path_aggregate_per_10s": 5000,
    "geo_tcp_reject_sess_rate": 200,
    "ja4_enabled": false,
    "ja4_req_per_10s": 100,
    "ja4_action": "challenge"
  },
  "pow": {
    "enabled": true,
    "difficulty": 18,
    "cookie_ttl": 86400,
    "always_challenge": false,
    "exempt_crawlers": true,
    "bind_ip": true,
    "botcheck_per_10s": 1000
  },
  "admission": {
    "enabled": false,
    "credit_required": 5,
    "credit_window": "60s",
    "known_ttl": "24h",
    "unknown_conn_rate": 1500,
    "table_size": "1m"
  },
  "ssl": {
    "mode": "acme",
    "acme_provider": "zerossl",
    "acme_email": null,
    "https_redirect": false,
    "cert_status": "none",
    "cert_issued": null,
    "cert_expires": null,
    "cert_last_renewal": null,
    "days_remaining": null
  },
  "aliases": [
    {
      "fqdn": "www.a.example.com",
      "ssl": {
        "mode": "acme",
        "acme_provider": "zerossl",
        "acme_email": null,
        "https_redirect": false,
        "cert_status": "none",
        "cert_issued": null,
        "cert_expires": null,
        "cert_last_renewal": null,
        "days_remaining": null
      }
    }
  ],
  "cache": {
    "enabled": true
  },
  "slow_http": {
    "enabled": true,
    "timeout_http_request": "5s",
    "timeout_client_fin": "5s"
  },
  "tcp_timeout": {
    "client": "30s",
    "server": "30s",
    "tunnel": "30s"
  },
  "udp_timeout": {
    "idle": "300s",
    "unreplied": "30s",
    "unreplied_pkts": 32,
    "src_pps": 0
  },
  "sni_policy": "allowlist",
  "proxy_protocol": false,
  "tls_termination": false,
  "priority": {
    "enabled": true,
    "session_signal": []
  },
  "response_headers": {
    "hsts": {
      "enabled": false,
      "max_age": 31536000,
      "include_subdomains": false,
      "preload": false
    },
    "cors": {
      "enabled": false,
      "rules": []
    },
    "x_frame_options": null,
    "x_content_type_options": false,
    "referrer_policy": null
  },
  "content_negotiation": {
    "enabled": false,
    "encodings": [
      "zstd",
      "gzip"
    ],
    "file_types": [
      "css",
      "js",
      "html"
    ]
  },
  "logging": {
    "enabled": false
  },
  "threat_intel": {
    "mode": "log",
    "abuse": "log",
    "tor": "log"
  },
  "redirects": [],
  "origin_host": null,
  "origin_sni": null,
  "maintenance_mode": false,
  "auto_mitigation": false,
  "defense_posture": null,
  "protection_allowlist": null,
  "edges": [],
  "protection_rules": [],
  "append_trailing_slash": false,
  "exceed_no_rtbh": false,
  "exceed_no_flowspec": false,
  "origin_kind": "dynamic",
  "alias_limit": 20,
  "serving_edges": [
    "shared-1"
  ],
  "posture_drift": [],
  "baseline": null
}

Fields

Part of the service: protection_rules and protection_allowlist, sent with PATCH /services/{fqdn}. A list is replaced as a whole: read the service, change the list, send it back complete.

ProtectionRule

Field Type Notes
name string may be null; Letters, digits, dot, dash and underscore, up to 40 characters, unique in the vService; logged as pr_hit=-. Omit it and the server names the rule rule. The prefixes rate-, posture-, ti- and allowlist- are reserved
match_mode string one of and, or; default "and"; Flat match only: and (every key must match) or or (one key is enough). The server stores the conditions as a group (any_of or all_of) and returns and here
negate boolean default false; Flat match only: the rule matches every request the match does not; rate is not inverted. The server stores it as not on the group and returns false here
match MatchNode Which requests the rule matches
rate RuleRate may be null; Extra condition: the source's request rate
action (required) string one of allow, skip, deny, challenge, block, drop; allow: exempt from the rules below, the blocklists, threat intelligence, the rate limits and proof-of-work; the WAF still inspects. skip: the same, for the stages in skip only. deny: 403. block: 429. drop: close the connection without an answer. challenge: proof-of-work unless solved, only with pow.enabled; the checks after the rules still run first. On tcp and anyconnect vServices deny and block refuse the connection; skip and challenge do not exist there
skip list of string may be null; skip rules only: the stages to skip. Omit it to skip all five, like allow
enabled boolean default true; false keeps the rule without applying it
bind_to string may be null; Apply the rule only to requests for this hostname: the FQDN or one of its aliases. Null: every hostname. HTTPS only
_meta object may be null; Set by ZERO-PROTECT on rules it adds itself: source (posture, allowlist or emergency) and for time-limited rules expires_at, after which the edges stop applying the rule. Send it back unchanged
action Effect
allow exempt from every later stage: later rules, blocklists, threat intelligence, all rate limits, proof-of-work. Only the WAF still inspects; slow-HTTP timeouts apply before any rule
skip exempt only from the stages listed in skip; without skip all five, like allow
deny 403 with your branded error page
block 429
drop connection dropped without an answer
challenge proof-of-work challenge

MatchNode

Field Type Notes
geo list of string at most 500; may be null; Countries of the source address: ISO 3166-1 alpha-2 codes such as DE, or group ids from GET /reference/geo-groups
asn list of integer at most 500; may be null; AS numbers of the source address
ip list of string at most 500; may be null; Source addresses or CIDR ranges, IPv4 or IPv6
path list of string at most 100; may be null; Request paths without query string, after the edge normalised them, case-sensitive: exact, or a prefix when the entry ends in *. # is refused
path_class string one of auth, admin; may be null; Built-in path group: auth (login paths such as /wp-login.php or /login) or admin (admin areas such as /wp-admin/)
crawler list of string at most 100; may be null; Crawler names from GET /reference/crawlers, recognised by the crawler's published source networks, not by its User-Agent
fingerprint list of string at most 500; may be null; JA4 TLS fingerprints of the client, exact
user_agent list of string at most 100; may be null; Text anywhere in the User-Agent header, case-insensitive; backslashes and double quotes are dropped
referer_host list of string at most 100; may be null; Host of the Referer header, case-insensitive: a hostname, *.example.com for its subdomains (not example.com itself), or none for a request without Referer
method list of string at most 7; may be null; HTTP methods. HTTPS only
content_type list of string at most 20; may be null; Media types the Content-Type header starts with, case-insensitive: lower-case type/subtype without parameters, such as multipart/form-data. HTTPS only
any_of list of MatchNode at most 20; may be null; Group: matches when at least one child node matches. 1 to 20 children, groups at most 2 levels deep; besides not the node holds nothing else
all_of list of MatchNode at most 20; may be null; Group: matches when every child node matches. 1 to 20 children, groups at most 2 levels deep; besides not the node holds nothing else
not boolean may be null; Inverts this node. Only in a match with any_of or all_of; a flat match uses the rule's negate
Key Matches
path request path; a trailing * makes a prefix; # is rejected
path_class a built-in path group, no list of paths needed
geo country of the source address (DE; stored in capitals), or a group written out (Europe, European-Union, Africa)
ip address or CIDR
asn AS number of the source network; one of GET /reference/asns, others are refused
fingerprint JA4 fingerprint of the client's TLS stack
user_agent text anywhere in User-Agent, case-insensitive
crawler a known crawler (google, bing, see reference data) by its source networks, not by its header
referer_host host of Referer; *.example.com covers subdomains, none a request without one
method HTTP method: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS; HTTPS services only
content_type media type the Content-Type header starts with, lower-case type/subtype without parameters (multipart/form-data); HTTPS services only
any_of, all_of group: at least one, or every, child node matches; up to two levels
not inverts this node

RuleRate

Field Type Notes
threshold (required) integer 1–; Requests of the source to the vService within window, all paths together, above which the rule fires
window (required) string one of 10s, 30s, 1m, 100s, 5m, 10m; Length of the counting window

ProtectionAllowlist

Field Type Notes
countries list of string at most 500; Countries of the source, ISO 3166-1 alpha-2
asns list of integer at most 500; AS numbers of the source
ips list of string Addresses or ranges, up to 128, none broader than /16 (IPv4) or /32 (IPv6). They are also exempt from the layer 3/4 limits at the edge
paths list of string at most 500; Request paths: exact, or a prefix when the entry ends in *

Example

Append a rule to the existing list. Deny one network:

RULE='{"name": "deny-as64496", "match": {"asn": [64496]}, "action": "deny"}'

Or answer 429 to requests for /search from a source that sent more than 50 requests, to any path, in 10 s:

RULE='{"name": "search-flood", "match": {"path": ["/search*"]},
       "rate": {"threshold": 50, "window": "10s"}, "action": "block"}'
curl -s -H "Authorization: Bearer $ZP_KEY" $API/services/www.example.com \
  | jq --argjson r "$RULE" '{protection_rules: (.protection_rules + [$r])}' \
  | curl -s -X PATCH -H "Authorization: Bearer $ZP_KEY" -H 'Content-Type: application/json' \
      $API/services/www.example.com -d @-

Behaviour

  • Rules run top to bottom, and between rules it is AND: each deny ends the request on its own, so a chain of negated deny rules narrows, it never widens.
  • Inside a rule, entries of one key are OR-ed and keys combine by match_mode; negate inverts the whole match. For "(A or B) and C" use any_of/all_of with not; such a rule carries no match_mode or negate.
  • The API stores every rule as a group and returns it that way; a flat rule stays valid input. TCP and AnyConnect services take only rules a flat match can express.
  • rate is an extra condition per source on top of the match, negate included: the rule fires only when the source sent more than threshold requests within window. All requests of the source count, not only those the rule matches.
  • Keep allow narrow, an address together with the path it needs, and never a whole country or consumer network. Prefer skip with the stages to bypass (HTTPS services only; an empty list is rejected). Both must come before the rules they bypass.
  • name: letters, digits, ., -, _, up to 40 characters, unique per service; logged as pr_hit=<name>-<action>. Without one the rule is rule<position>.
  • bind_to limits a rule to one hostname of the service's aliases.
  • Allowlist entries become allow rules at the top of the list, and automatic mitigation never acts against them. Up to 200 entries, of which 128 addresses or ranges, none broader than /16 (IPv4) or /32 (IPv6).
  • Up to 100 of your own rules per service; posture and allowlist rules do not count. See Limits.

In the portal: the rule editor of your service; drag and drop sets the order.