Docs / Writing rules
Reference

Writing rules

How to author detection, sanitization, rate-limiting, and exception rules. Rules are JSON objects you put in rules_request / rules_response, or SecLang strings in custom_secrules. This page is the full technical reference.

Rule format

A rule is a JSON object. It fires when all of its conditions match, then runs its action. Add it to the plugin config under rules_request (request phase) or rules_response (response phase).

a complete rulejson
{
  "id": "1234",
  "phase": "access",
  "conditions": [
    {
      "op": "rx",
      "transform": ["urlDecodeUni"],
      "value": "['\"`]+.*['\"`;&|]+",
      "variables": ["request.arg.value"],
      "multi_match": false
    }
  ],
  "action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
  "log": true,
  "message": "Example injection rule",
  "tags": ["injection", "virtual-patching"]
}
FieldTypeDescription
idstringUnique identifier. Used in logs and by rule controls / overrides.
phasestringaccess, header_filter, body_filter, or mcp_event. See Phases.
conditionsarrayOne or more condition objects, AND-ed together. See Conditions.
actionobjectWhat to do when the rule fires. See Actions.
messagestringHuman-readable description, written to the audit log.
tagsarrayLabels used by overrides and rule controls (e.g. attack-sqli).
logbooleanWhether a match is written to the audit log.
rule_controlarrayOptional self-modifications applied to this or other rules. See Rule controls.

Conditions

Each condition applies its operator to one or more variables, optionally after a transformation chain. A condition matches if the operator matches on any of its resolved values.

FieldTypeDescription
variablesarrayWhat to inspect (e.g. request.arg.value, request.header.value:host). See Variables.
opstringThe operator (e.g. rx, eq, libinjection_sqli). See Operators.
valuestringThe operator argument (pattern, number, token list…). Use "" for operators that take none (isSet, libinjection_*).
transformarrayTransformations applied to each value before the operator runs, in order. Omit or [] for none. See Transformations.
negatedbooleanInvert the match. See Negation.
multi_matchbooleanWhen true, the operator is also tested against each intermediate transform result, not only the final one.
ChainingMultiple conditions form a chain (logical AND). A later condition can inspect an earlier one's match via matched.value and the regex capture groups group:0, group:1, …

Phases

PhaseRunsCan inspect
accessBefore the request reaches your appMethod, path, query, headers, cookies, and the parsed body. Can block, sanitize, or modify the request. Most rules live here.
header_filterAfter the app responds, before headers go to the clientThe request plus the upstream response status and headers.
body_filterWhile streaming the response bodyResponse body chunks. Used internally for MCP SSE reassembly.
mcp_eventPer reassembled SSE event (Karna-native)A single Model Context Protocol event. Rules can drop / replace / terminate / inject events. Non-MCP requests skip this phase.

Variables

Variables name what a condition inspects. Many are arrays (e.g. all header values). Append :<selector> to target a specific named element, for example request.header.value:host or request.arg.value:username.

VariableResolves to
request.arg.valueValues from query string + parsed body (ModSec ARGS). Canonical for "any argument value".
request.arg.nameArgument names from query string + parsed body.
request.query.value / .nameValues / names from the query string only.
request.body.urlencode.value:<name>A specific urlencoded form field.
request.body.json.value:<path>A value at a JSON path in the parsed body.
request.bodyRaw body (for urlencoded / text bodies).
request.header.value / .nameRequest header values / names. Target one with :<header>.
request.header_no_fp.valueHeader values excluding the most FP-prone ones (User-Agent, Referer, …).
request.cookie.value / .nameCookie values / names.
request.raw_pathURL path, not normalized, no query string.
request.basenameLast segment of the path (e.g. index.php).
request.methodHTTP method.
request.fileUploaded file names / multipart param names.
request.body.multipart.filenameMultipart filenames.
request.body.multipart.header.valueMultipart part header values.
request.header.referer.{path,query,scheme,host}Components of the Referer URL.
response.set_cookie.value / .nameValues / names from Set-Cookie (response phases).
response.header.name:<name>A specific response header (response phases).
matched.valueThe value matched by an earlier condition in the chain.
group:<n>Capture group n from an earlier rx match (group:0 = full match).
tx:<name> / var:<name>Transaction variables (CRS TX:), e.g. var:paranoia_level.
redis.<key>Inspect a Redis key (read-only). Everything after redis. is the key name, with macros allowed (redis.ban:%{remote_addr}). The operator picks the command — see Redis inspection. Needs redis_inspect_enabled.
geoip.* / asn.*Enrichment from a sibling plugin (geoip.country_code, asn.org, …), when present.
mcp.*MCP fields (mcp.method, mcp.tool.name, …) when mcp_enabled.
Targeting a named argTo match a specific argument by name, prefer request.arg.value:<name> (covers query + urlencoded body).

Operators

The op field names one operator. An operator not in this set never matches. CRS operators (@detectSQLi, @streq, @detectXSS, …) are translated to these names when SecLang is parsed.

opNegatableDescription
rxyesRegex match against the value. With the RE2 engine this is linear-time and ReDoS-safe.
eqyesExact equality (string or number).
ge / gt / lt / leyesNumeric ordering. Non-numeric input fails closed.
beginsWith / endsWithyesString prefix / suffix match.
containsyesLiteral substring (case-sensitive).
withinyesValue is one of the whitespace-separated tokens in value.
isSetyesWhether the variable resolves to anything. With negated: true, fires on absence.
pm / pmFromFileyesPhrase match: any token in value (or a file) appears in the variable.
ipMatchyesIPv4/IPv6/CIDR match against a comma- or space-separated list.
libinjection_sqli / libinjection_xssyesSQLi / XSS detection via libinjection.
validateUrlEncodingyesMatches on malformed %XX sequences.
validateUtf8EncodingyesMatches when input is not valid UTF-8.
validateByteRangeyesMatches when any byte falls outside the ranges in value (e.g. "32-126,9,10,13").
unconditionalMatchn/aAlways true. Used as the predicate of chains gated by other conditions' side-effects.
mcp_method_inn/aThe JSON-RPC method is in value (MCP).
mcp_jsonrpc_validn/aThe body is a valid JSON-RPC 2.0 envelope (MCP).
redis_sismemberyesThe value is a member of the Redis SET named by the redis.<key> variable. Negated = not a member (allowlist). Needs redis_inspect_enabled.
redis_hexistsyesThe Redis HASH named by the redis.<key> variable has a field equal to value. Negated = field absent. Needs redis_inspect_enabled.
Not implementedSome CRS operators have no equivalent: @ipMatchFromFile, @verifyCC, @verifySSN, @geoLookup, @inspectFile. Rules that need them are skipped at parse time with a WARN line in the Kong error log.

Negation

Every binary operator can be negated. The canonical form is a separate boolean field, not a ! prefix:

negated conditionjson
{
  "op": "isSet",
  "negated": true,
  "value": "",
  "variables": ["request.header.value:content-type"]
}

Semantics: a negated condition fires when the positive match fails and the value is present. A missing variable does not satisfy a negated condition. The one exception is isSet with negated: true, which is the way to spell "variable is absent" and so fires on a missing variable.

For back-compat the engine still accepts "op": "!rx", but new rules should use negated.

Transformations

Listed in transform, applied in order to each value before the operator runs. There are no implicit transforms — what you list is what runs. Results are cached per request.

TransformEffect
lowercaseLowercase ASCII.
urlDecodeUniURL-decode, including %uHHHH sequences (alias: urlDecode).
hexSequenceDecodeDecode %HH sequences (one pass).
htmlEntityDecodeDecode HTML entities (&#xHH;, &quot;, …).
jsDecodeDecode JavaScript \uHHHH / fullwidth escapes.
cssDecodeDecode CSS escapes.
escapeSeqDecodeDecode ANSI-C escapes (\n, \xHH, …).
base64DecodeBase64-decode (alias: base64decode).
removeNullsStrip NUL bytes.
removeWhitespaceStrip all whitespace.
compressWhitespaceCollapse runs of whitespace to a single space.
replaceCommentsReplace /* */ and // with a space.
removeCommentsCharStrip comment characters (/*, */, //, #).
normalisePathNormalize path slashes and . / .. segments (alias: normalizePath).
normalizePathWinLike above, treating \ as a separator.
cmdLineCommand-line normalization (strip quoting, collapse spaces, lowercase).
utf8toUnicodeConvert UTF-8 to %uHHHH form.
lengthReplace the value with its length (a number).
sha1SHA-1 digest (raw bytes).
hexEncodeHex-encode bytes.

Actions

The action object decides what happens on a match. Side-effect actions (set_variable, set_log_fields, redis_incr_key, redis_set, redis_sadd, redis_del) fire whether or not the request is blocked. Terminal actions only block when engine_blocking_mode is on.

ActionWhat it does
fixed_responseTerminate with a fixed status / headers / body (the standard block).
fix_matched_partsSanitize the matched targets in place and let the request through. Takes precedence over fixed_response if both are present.
rate_limitRedis fixed-window counter; returns 429 when the limit is exceeded.
redis_incr_keyIncrement a Redis key with a TTL (no terminal effect).
redis_set / redis_sadd / redis_delWrite cluster-wide state on a match: set a key (with optional TTL), add a member to a set, or delete a key. The auto-ban primitive.
set_variableWrite a value into kong.ctx.shared or kong.ctx.plugin for sibling plugins / later phases.
set_log_fieldsAdd custom fields to the audit log entry.

fixed_response

actionjson
"action": {
  "fixed_response": {
    "status_code": 403,
    "headers": { "content-type": "text/plain" },
    "body": "Forbidden\r\n"
  }
}

fix_matched_parts

Strips remove_chars_pattern from every matched target and forwards upstream. The audit log marks the entry action: "sanitized".

actionjson
"action": {
  "fix_matched_parts": { "remove_chars_pattern": "[\"';&|`]*" }
}

rate_limit

FieldDefaultPurpose
key%{remote_addr}Counter cardinality. Macros: %{remote_addr}, %{request.method}, %{request.host}, %{request.scheme}, %{request.path}.
limit0Max requests in the window.
window_seconds60Window length / counter TTL.
response429Optional status_code / body / headers. Retry-After is set automatically.

redis_set / redis_sadd / redis_del

Write cluster-wide state on a match. These are fire-and-forget side effects: synchronous in the access phase, deferred to a timer in later phases, and they never block the request themselves. Keys, values, and members are macro-resolved (%{remote_addr}, %{request.method|host|scheme|path}, %{request_headers.X}). Pair them with a redis.<key> inspection rule to close an auto-ban loop — see the auto-ban example.

ActionFieldsRedis command
redis_setkey, value (default "1"), expireSET key value, plus EX expire when expire is set.
redis_saddkey, member, expireSADD key member, plus EXPIRE key expire when expire is set.
redis_delkeyDEL key (manual unban / clear).
ban the client IP for 10 minutesjson
"action": {
  "redis_set": {
    "key": "ban:%{remote_addr}",
    "value": "1",
    "expire": 600
  }
}

set_variable

type is required (sharedkong.ctx.shared, pluginkong.ctx.plugin). String values support %{var} macros resolved against the request.

actionjson
"action": {
  "set_variable": {
    "name": "skip_js_challenge",
    "value": true,
    "type": "shared"
  }
}

set_log_fields

actionjson
"action": {
  "set_log_fields": [
    { "name": "username", "value": "%{request.body.urlencode.value:username}" }
  ]
}

Redis inspection

A redis.<key> variable reads shared state from Redis at request time. The variable names the key (everything after redis., macros allowed), and the operator is the test — it decides which read command runs. Reads are off unless redis_inspect_enabled is on, and the client is locked to a read-only command whitelist.

OperatorRedis commandMeaning
isSetEXISTSKey exists. negated: true fires on absence (allowlist). The canonical ban / ACL-TTL check.
eq / rx / contains / beginsWithGETRead the value, then compare with the operator. An absent key never matches.
gt / lt / ge / leGETRead the value and compare numerically (counters / scores stored as strings).
redis_sismemberSISMEMBERvalue is a member of the set. Negatable.
redis_hexistsHEXISTSThe hash has a field named value. Negatable.

The key macros are %{remote_addr}, %{request.method|host|scheme|path}, and %{request_headers.X}. The value needle resolves %{remote_addr} and %{request.*}. When Redis is unreachable, redis_on_error decides the outcome (default skip — the condition does not match and traffic flows).

block IPs present in a banlist keyjson
{
  "op": "isSet",
  "value": "",
  "variables": ["redis.ban:%{remote_addr}"]
}
reject a token found in a revoked-tokens setjson
{
  "op": "redis_sismember",
  "value": "%{request_headers.authorization}",
  "variables": ["redis.revoked_tokens"]
}

Rule controls

A rule's rule_control array modifies rules (itself or others by id / tag). Useful for carving out false positives and patching CRS rules without editing the pack.

ControlEffect
remove_ruleSkip a rule entirely ({ "rule_id": "1234" }).
remove_rules_by_tagSkip all rules with a tag.
remove_variable_from_rule_conditionsDrop a variable from every condition of a rule.
remove_variable_rxDrop variables whose key matches a regex (great for libinjection header FPs).
remove_target_rule_by_patternDrop matched-key targets from a rule by Lua pattern.
remove_target_tag_by_patternSame, for all rules with a tag.
change_rule_actionReplace a rule's action.
change_condition_tfuncReplace a condition's transform chain (condition_number is 1-based).
change_condition_valueReplace a condition's operator value.
replace_condition / remove_condition / add_conditionReplace, delete, or append a condition.
carve out libinjection header false positivesjson
"rule_control": [
  {
    "remove_variable_rx": {
      "name": "request.header.value",
      "rx": ".*(?:[Uu]ser\\-[Aa]gent|[Rr]eferer|[Aa]uthorization).*"
    }
  }
]

CRS overrides

To change what existing CRS rules do without rewriting them, use the config-level rule_action_overrides and rule_response_overrides arrays (see Configuration). Each entry has a selector and a payload; the first matching entry wins.

SelectorBehaviour
idsOR-match against rule.id.
id_rangesNumeric range, inclusive (e.g. "941000-941999").
tagsAny tag in the list intersects rule.tags.
except_ids / except_tagsExclude a rule even on a positive match.
anytrue matches every rule (use with except_*).
switch the XSS family to sanitizejson
{
  "selector": { "tags": ["attack-xss"] },
  "action": { "type": "fix", "remove_chars_pattern": "[<>\"'&;]" }
}

SecLang rules

If you prefer ModSecurity syntax, put raw SecRule strings in custom_secrules. They are parsed at worker start and added to the global pool alongside the CRS. Only the canonical SecRule <vars> "<op>" "<actions>" form is parsed; SecRule* derivatives are skipped.

configjson
"custom_secrules": [
  "SecRule ARGS:debug \"@streq 1\" \"id:9001,phase:1,deny,status:403,msg:'debug arg blocked'\""
]

Example: detect & block

Block header-borne SQL injection, with a carve-out for FP-prone headers.

rules_request entryjson
{
  "id": "2201",
  "phase": "access",
  "conditions": [
    {
      "op": "libinjection_sqli",
      "transform": ["urlDecodeUni"],
      "value": "",
      "variables": ["request.header.value"]
    }
  ],
  "action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
  "message": "SQL Injection: header-borne",
  "rule_control": [
    {
      "remove_variable_rx": {
        "name": "request.header.value",
        "rx": ".*(?:[Uu]ser\\-[Aa]gent|[Rr]eferer|[Aa]uthorization).*"
      }
    }
  ],
  "tags": ["injection", "attack-sqli"]
}

Example: sanitize, don't block

Strip XSS-shape characters from the name argument instead of blocking. GET /signup?name=O'Brien reaches the app as name=OBrien.

rules_request entryjson
{
  "id": "sanitize-name-field",
  "phase": "access",
  "log": true,
  "conditions": [
    {
      "op": "rx",
      "transform": [],
      "value": "[<>\"'&;]",
      "variables": ["request.arg.value:name"]
    }
  ],
  "action": { "fix_matched_parts": { "remove_chars_pattern": "[<>\"'&;]" } },
  "tags": ["sanitize"],
  "message": "neutralize XSS-shape chars in name"
}

Example: rate limit

Cap /api/login to 5 attempts per minute per source IP.

rules_request entryjson
{
  "id": "rl-login-per-ip",
  "phase": "access",
  "conditions": [
    {
      "op": "beginsWith",
      "transform": [],
      "value": "/api/login",
      "variables": ["request.raw_path"]
    }
  ],
  "action": {
    "rate_limit": {
      "key": "%{remote_addr}",
      "limit": 5,
      "window_seconds": 60,
      "response": { "status_code": 429, "body": "Too many login attempts.\r\n" }
    }
  },
  "message": "login rate limit",
  "tags": ["ratelimit", "auth"]
}

Example: counter + threshold

Two rules: the first increments a Redis counter on a failed login (no session cookie set on a login POST); the second blocks once the counter is high. The read needs redis_inspect_enabled, and both rules key on the same Redis key so the write and read line up.

1 — count failed loginsjson
{
  "id": "count-failed-login",
  "phase": "header_filter",
  "conditions": [
    {
      "op": "beginsWith",
      "value": "/login",
      "variables": ["request.raw_path"]
    },
    {
      "op": "eq",
      "value": "POST",
      "variables": ["request.method"]
    },
    {
      "op": "isSet",
      "negated": true,
      "value": "",
      "variables": ["response.set_cookie.name:session"]
    }
  ],
  "action": {
    "redis_incr_key": { "key": "failed_login:%{remote_addr}", "expire": 300 }
  },
  "log": false
}
2 — block over thresholdjson
{
  "id": "block-failed-login",
  "phase": "access",
  "conditions": [
    {
      "op": "ge",
      "value": "5",
      "variables": ["redis.failed_login:%{remote_addr}"]
    }
  ],
  "action": { "fixed_response": { "status_code": 403, "body": "Too many login attempts.\r\n" } },
  "log": false
}

Example: distributed auto-ban

Two rules close the loop across every Kong node. The first writes a ban with a TTL when it catches an attack; the second blocks any request from a banned IP. Needs redis_inspect_enabled for the read and a shared Redis so all nodes see the same ban.

1 — ban on SQLi (any node)json
{
  "id": "ban-on-sqli",
  "phase": "access",
  "conditions": [
    {
      "op": "libinjection_sqli",
      "transform": ["urlDecodeUni"],
      "value": "",
      "variables": ["request.arg.value"]
    }
  ],
  "action": {
    "redis_set": {
      "key": "ban:%{remote_addr}",
      "value": "1",
      "expire": 600
    },
    "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" }
  },
  "message": "SQLi — ban source for 10 min",
  "tags": ["attack-sqli"]
}
2 — block banned IPs (every node)json
{
  "id": "block-banned",
  "phase": "access",
  "conditions": [
    {
      "op": "isSet",
      "value": "",
      "variables": ["redis.ban:%{remote_addr}"]
    }
  ],
  "action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
  "message": "source is banned",
  "tags": ["banlist"]
}

Example: chained rule

Conditions are AND-ed. This rule fires only on a POST to /upload carrying a script-like filename.

rules_request entryjson
{
  "id": "block-script-upload",
  "phase": "access",
  "conditions": [
    {
      "op": "eq",
      "value": "POST",
      "variables": ["request.method"]
    },
    {
      "op": "beginsWith",
      "value": "/upload",
      "variables": ["request.raw_path"]
    },
    {
      "op": "rx",
      "transform": ["lowercase"],
      "value": "\\.(php|phtml|jsp|asp)$",
      "variables": ["request.file"]
    }
  ],
  "action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
  "message": "script upload blocked",
  "tags": ["upload"]
}