New — Iris AI drafts your firewall, routing & rate-limit rules. Explore AI features →
Back to Blog
securitywafedgeproduct

Deploy Edge Security Rules Without Breaking Prod

Detect-first defaults, staged spec imports, and a blast-radius preview that replays your own real traffic through a rule before you save it — so a typo doesn't take prod down.

10 min read

Shipping a security rule to production carries a particular kind of dread. A WAF rule that's slightly too broad, an IP restriction with a typo, a rate limit set an order of magnitude too low — any one of them can turn a routine change into an incident, and the blast radius is your live traffic. We designed the Ngris traffic-policy engine around one blunt assumption: operators make mistakes, and a mistake shouldn't take you down.

This post walks through the concrete mechanisms that make edge security rules safe to deploy — detect-first defaults, a blast-radius preview that replays your real traffic before you save, staged spec imports, phase-pinning at the save gate, and a fail-safe config reload — and it's honest about where the safety net has holes.

Detect first, block later

The two rule types with the largest potential blast radius — the built-in WAF and JA3 bot management — both default to detect mode. Detect logs a match and lets the request through. It never blocks. The point is that enabling the WAF on a live endpoint cannot break traffic on day one: you turn it on, watch the logs, confirm the matches are actual attacks rather than false positives on a legitimate payload, and only then flip mode to block.

# waf rule config — detect is the default; core is the in-binary baseline ruleset

{   "mode": "detect",   "ruleset": "core",   "inspect_body": false,   "max_body_kb": 128 }

The WAF is a Coraza (Go ModSecurity-compatible) engine consuming SecLang. The default core ruleset is a curated in-binary baseline — SQLi, XSS, path traversal, command injection, Log4Shell/JNDI, scanner user-agents — not the full OWASP CRS; you can paste the full CRS into custom_rules if you want it. Body inspection is off by default and, when on, caps at 128 KB (hard limit 1 MiB), reads only a prefix in-memory, and always re-attaches the full body to the origin. When mode is block, a match returns a plain 403. Any value other than block — including empty — resolves to detect, so you can never accidentally deploy a blocking WAF by leaving a field blank.

Bot management works the same way, matching the client's JA3 TLS fingerprint against your denylist or allowlist. It also defaults to detect, and block_missing is off by default — a request with no computable fingerprint is allowed, because a missing fingerprint is not proof of a bot. The intended workflow is to read the exact 32-hex JA3 straight out of the detect logs, then promote it:

# a detect-mode log line prints the fingerprint verbatim

bot-management: DETECT endpoint=42 ip=203.0.113.7 ja3="e7d705a3286e19ea42f587b344ee6865" reason=... (not blocked)

# copy that hash into the denylist, then flip mode to block

{ "mode": "block", "ja3_denylist": ["e7d705a3286e19ea42f587b344ee6865"], "block_missing": false }

To be clear about what bot management is: JA3 fingerprint allow/deny only. There is no JA4, no CAPTCHA, and no interactive JS challenge — the only two outcomes are allow or a 403. It's honest about being a fingerprint gate, not a challenge system.

Preview the blast radius before you save

Detect mode is great for the two rule types that support it, but most rules — IP restrictions, rate limits, redirects — don't have a "just log it" mode. For those, the honest tool is the blast-radius preview. It takes a proposed, unsaved rule and replays your account's recently-captured real traffic through it, then tells you what would have happened.

# POST /endpoints/{uuid}/policies/preview (no DB writes — pure dry run)

{   "rule_kind": "traffic_policy",   "op": "add",   "window_minutes": 120,   "rule_json": { "rule_type": "rate-limit", "config": { "limit": 20, "window": 60, "scope": "ip" } } }

The important property: the preview evaluates your proposed rule with the same shared/policyeval package the edge is built on — the same CEL matching and rule-condition logic — rather than a bespoke re-implementation. That shared foundation keeps the preview closely aligned with how the rule behaves at the edge, so the numbers are a faithful estimate of the real blast radius, not a guess.

It replays stored historical traffic (not a live shadow): rows from your captured traffic logs within a window, default 120 minutes, clamped to a 24-hour maximum, up to 5,000 rows, under a 3-second wall-clock budget. Each historical request is reconstructed from its stored row — geo and ASN from the row's recorded fields, not a live lookup. If the window is sampled, counts are scaled and an estimated flag is set so you know the numbers are extrapolated.

The response is a compact impact summary with a single verdict:

# response — the fields that matter for a go / no-go call

{   "capture_level": "full",   "sampled_rows": 4820,   "total_rows": 4820,   "newly_blocked": 31,   "affected_successful_reqs": 0,   "affected_share_pct": 0.6,   "unsimulated": 0,   "verdict": "safe" }

The number to watch is affected_successful_reqs — requests that were already succeeding (2xx/3xx, not already blocked) that your change would newly stop. That's the "would this page someone at 2am" number, kept separate from things you probably meant to block.

The five verdicts

The verdict is deliberately conservative — a safe verdict is hard to earn, because it's meant to eventually gate unattended automation. There are exactly five values:

  • insufficient_data — capture is off, or there were no rows to replay. You get a clean, all-zero card, never a scary number invented from nothing.
  • low_confidence — fewer than 50 sampled rows. Not enough evidence to trust either way.
  • safe — the affected share is ≤ 1% and zero previously-successful requests are cut and every row was fully simulatable (or it's a pure relax that only re-admits traffic).
  • review — nothing alarming, but worth a human glance. This is also where the verdict lands when some rows couldn't be simulated.
  • risky — the change would cut real successful traffic: more than 10% affected share, or at least 25 previously-successful requests newly stopped.

One honest caveat baked into the design: the preview only truly replays the deterministic rule types — restrict-ips, rate-limit, redirect, force-https, custom-response, url-rewrite, add-headers, remove-headers, plus firewall and rate-limit policies. Rules like WAF, bot management, JWT/API-key validation, webhook, validate-body, CORS, and cache-response are not simulatable; every matching row is counted as unsimulated, which pushes the verdict toward review rather than pretending to know. The preview would rather tell you it can't model a rule than hand you false confidence — and for the two big unmodelable ones, detect mode is the preview.

Finally: the preview is advisory. It returns an impact summary and a verdict; it does not block your save. It gives you the number and lets you make the call.

Import a spec, staged and disabled

If you generate rules from an OpenAPI document, the same safety instinct applies. The import parses an uploaded or pasted OpenAPI 3 spec and generates one validate-body rule per operation that declares a JSON request body. In the default detect mode, every generated rule is staged disabled (enabled=false) — an import can never start blocking live traffic on its own. You review the plan, then re-import with mode: "enforce" to actually turn the rules on.

# two-step: preview the plan (no writes), then apply

POST /endpoints/{uuid}/policies/import-openapi/preview -> plan + plan_hash POST /endpoints/{uuid}/policies/import-openapi -> writes the managed "openapi-import" policy

Preview is a pure dry run and returns a plan_hash; apply re-computes that hash and rejects with a 409 if the spec changed since you previewed it — a TOCTOU guard against applying a plan you never actually saw. Ingest is bounded and fail-closed: a 2 MiB spec cap, at most 500 operations and 500 generated rules, an 8-second parse deadline, and external $ref resolution fully disabled — there's deliberately no URL-fetch input, so importing a spec can't be turned into an SSRF or a local-file read. Apply atomically replaces only the single managed policy named openapi-import; your hand-written policies are never read or touched.

The save gate catches footguns

Some mistakes are silent, which makes them worse. A number of rule types only ever run in the on_http_request phase — the enforcement types (jwt-validation, api-key-validation, require-client-cert, verify-webhook, validate-body) plus url-rewrite, redirect, custom-response, force-https, cache-response, bot-management, and set-vars. If one of those were saved in, say, on_http_response, it would look active in the UI but silently never execute — a fail-open bypass on exactly the rules you most want enforced.

So the save-time validator rejects it outright with a 400 rather than letting you deploy a security rule that does nothing:

# saving jwt-validation in the wrong phase is rejected, not silently ignored

400 "jwt-validation rule must be in the on_http_request phase"

WAF and CORS are pinned to on_http_request the same way; replace-body and compress-response to on_http_response. And evaluation order is deterministic — rules run by policy priority, then rule priority, then age, and the first rule whose action isn't "continue" wins and short-circuits — so the ordering the preview sees is the ordering production runs.

Fail-safe reload at the edge

The last layer is what happens when the edge itself hits trouble reloading your policies. When the tunnel-server rebuilds its in-memory policy cache and the traffic-policy load hits a transient database error, it aborts the rebuild and keeps the previous cache intact. The alternative — swapping in an empty, policy-less cache — would drop every endpoint's WAF, JWT, and IP rules fleet-wide for a cycle on a momentary DB blip. Failing safe here means a database hiccup can't quietly disable your security rules.

Where the net has holes

Honesty about the limits is part of the design, not a footnote:

  • The preview replays history, not a live shadow. A change is only as well-previewed as your captured window is representative. Keep capture enabled, and for low-traffic endpoints widen the window (up to 24 hours).
  • It can't simulate every rule. WAF and bot management in particular are counted as unsimulated and bias the verdict to review. For those, run detect mode — that is their preview.
  • The preview is advisory. It won't stop a bad save; it gives you the numbers to decide.
  • The WAF eval path fails open. On an engine build or compile error, the WAF allows traffic and logs that the endpoint is unprotected — an availability tradeoff, and the reason a custom SecLang ruleset isn't compiled at save time (save-time checks are a denylist, size, and NUL check only). If you paste custom rules, validate them carefully; a broken ruleset is accepted and leaves the endpoint open.
  • The reload fail-safe is specific. Only the core traffic-policy load error preserves the old cache; it's not a universal guarantee across every bundle the edge loads.

None of these mechanisms are magic. Put together, they add up to a simple posture: you can turn a rule on in observe mode, replay your real traffic through it against the same code the edge runs, stage a whole spec's worth of rules disabled, and trust that the save gate and the reload path won't let a typo or a DB blip quietly take you down.

Ship the rule, keep prod up

Enable detect mode, preview the blast radius against your own traffic, then flip to block.

Create a free account →
Ask an AI to summarise this page
Product
API Gateway Secure Tunnels WAF & Firewall Traffic Inspector
AI
Iris AI AI Gateway
More
Solutions Developers Pricing Enterprise Sign in Get Started Free
Iris