WAF Custom Action
A WAF Custom Action calls a Global Lua Module when a request meets the configured WAF action criteria. The module receives the WAF verdict and can return a response, present its own challenge, or let request processing continue.
Configuration
Create a Global Lua Module that exports the callbacks described below. Only
invokeis required.
In the application’s Page Rules WAF configuration, set Block Action to Custom Action, then pick your module in Module Name.
Configure WAF Rules, Paranoia Level, and Sensitivity Level, then save and release the page rule changes.

A request that already has valid Edge challenge clearance skips this action.
invoke Callback
Edge calls invoke(params) as a plain function, without an implicit self argument. params is a Lua table, not a JSON string. No additional request context argument is passed.
| Field | Lua type | Description |
|---|---|---|
time | number | Challenge creation time as a Unix timestamp in whole seconds. |
module_name | string | Selected Global Lua Module name. |
clearance_time | number or nil | Clearance duration after successful verification, in seconds. The Edge Admin console has no such field for Custom Action, so this is nil unless the rule was configured through the admin API. |
captcha_type | string | Always "custom". |
token | string | Encrypted, Base64-encoded token, already URI-escaped for use in challenge requests. Treat it as opaque. |
prev_url | string | Original request URI, including its query string, already URI-escaped. |
verdict | table | WAF verdict described below. |
WAF Verdict
| Field | Lua type | Description |
|---|---|---|
score | number | Score used for this action decision; accumulated across requests when Cross Request Mode is on. Explicit WAF decisions can use special score values, so do not assume the score is always positive. |
action | string | Always "custom-action", the value Edge uses for the Custom Action block action. |
threshold | number or nil | Score at which the action fires, as set by Sensitivity Level or Min Score; absent when no threshold applies. |
hit_types | Array of strings | Matched rule groups. Do not depend on their order. |
rule_sets | table or nil | Map from ruleset ID to a "score/threshold" string for rulesets that reached their individual thresholds. This is not a list of every matched ruleset and can be absent, including when Cross Request Mode is on. |
rules | Array of tables | Matched rules. Each entry contains rule_name (string, when set), rule_id (number), and group (string). |
For example, [12] = "10/5" in rule_sets means ruleset 12 scored 10 against its threshold of 5. The rules entries do not include per-rule scores, matched request data, or the complete WAF log record. Treat the verdict as read-only and handle optional fields being absent.
Return Values
| Return values | Behavior |
|---|---|
content, status | Return the response body with the numeric HTTP status and end the request. The default content type is text/html. |
content | Return the response body with HTTP 403 and end the request. |
nil, "pass" | Continue request processing without sending a response or issuing a clearance cookie. Other rules can still affect the request. |
nil or nil, 429 | Return HTTP 403 because no response body was provided. |
To return a status with an empty body, return an empty string, for example return "", 429. Return the body and status to Edge instead of printing the body before returning. A missing module or missing invoke callback results in HTTP 500.
Example: Reject with HTTP 429
This module returns a static rejection page. It does not implement a challenge or issue clearance:
local _M = {}
local function invoke(params)
return [[<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Request temporarily blocked</title></head>
<body><h1>Request temporarily blocked</h1><p>Please try again later.</p></body>
</html>]], 429
end
_M.invoke = invoke
return _M
For a custom challenge page, return your challenge HTML and 429 in the same way. This sets the status when the response is generated, without requiring a header filter to rewrite a 403 response. It affects only this custom response; it does not change the status of built-in block or challenge actions, or challenge verification failures.
To allow the current request after your own checks succeed, return nil, "pass". This does not grant clearance for subsequent requests.
Challenge Callbacks
invoke on its own is enough for a module that only rejects or passes requests. To run a full challenge — serve a page, check the answer, and then let the client through for a while — also export verify, and export create if the challenge page fetches its content separately.
Edge routes two fixed endpoints to these callbacks. Both identify your module from the token that invoke received, so the challenge page has to carry that token through.
For both callbacks, params is decoded from the token and contains time, module_name, clearance_time, captcha_type, and verdict. It does not contain the token and prev_url fields that Edge adds for invoke. The token and prev_url handed to invoke are already URI-escaped; do not encode them a second time when putting them in the query string or form body below.
create
GET /.edge-waf/create-captcha?token=TOKEN
Edge calls create(params, uri_args):
| Argument | Lua type | Description |
|---|---|---|
params | table | Decoded token payload, as described above. |
uri_args | table | Parsed query parameters, including token. Validate any other client-supplied value before using it. |
| Return value | Behavior |
|---|---|
| Non-empty string | Sent as the response body with HTTP 200. |
nil or "" | HTTP 403. |
Any second return value is ignored; the status is always 200 or 403. Edge sets no Content-Type on this response, so set one in the callback with ngx.header.content_type if the client needs it.
A request to this endpoint whose token is missing or cannot be decoded never reaches your module: it falls through to Edge’s built-in captcha image. A token that does reach a module without a create callback returns HTTP 500.
verify
POST /.edge-waf/edge-recaptcha
Content-Type: application/x-www-form-urlencoded
The body must carry token and prev_url unchanged from invoke, plus your own answer fields. Edge returns HTTP 403 if either is missing.
Edge calls verify(params, post_args):
| Argument | Lua type | Description |
|---|---|---|
params | table | Decoded token payload, as described above. |
post_args | table | Parsed form fields. Validate the client-supplied answer before trusting it. |
| Return value | Behavior |
|---|---|
true | Edge grants clearance, then returns HTTP 200 with the URI-unescaped prev_url as the body. The page navigates from there. |
false or nil | HTTP 403. |
Clearance is a waf-verify cookie valid for params.clearance_time seconds, or for 60 seconds when no clearance time was configured, which is the case for rules set up in the Edge Admin console. While that cookie is valid the WAF action is skipped entirely, so invoke is not called again. A module without a verify callback returns HTTP 500.
Example: A Complete Challenge
invoke serves a page carrying the token, the page asks create for the question, and verify checks the answer and grants clearance:
local _M = {}
local str_fmt = string.format
local PAGE = [[<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Checking your request</title></head>
<body>
<p id="question">Loading...</p>
<input id="answer"><button onclick="send()">Continue</button>
<script>
var token = "%s", prevUrl = "%s";
fetch("/.edge-waf/create-captcha?token=" + token)
.then(function (r) { return r.json(); })
.then(function (c) { document.getElementById("question").textContent = c.question; });
function send() {
fetch("/.edge-waf/edge-recaptcha", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: "token=" + token + "&prev_url=" + prevUrl
+ "&answer=" + encodeURIComponent(document.getElementById("answer").value)
}).then(function (r) {
if (r.status === 200) {
r.text().then(function (url) { location.replace(url); });
}
});
}
</script>
</body>
</html>]]
local function invoke(params)
return str_fmt(PAGE, params.token, params.prev_url), 429
end
_M.invoke = invoke
local function create(params, uri_args)
ngx.header.content_type = "application/json"
return [[{"question": "What is two plus three?"}]]
end
_M.create = create
local function verify(params, post_args)
if post_args.answer ~= "5" then
return false
end
return true
end
_M.verify = verify
return _M
A module can also run its own challenge endpoints instead of Edge’s: route your own URIs to create and verify with foreign-call() in a page rule, manage your own clearance cookie, and have invoke return nil, "pass" once that cookie is present. Called that way the callbacks get only the arguments the rule passes, not Edge’s params, so they read the request themselves.
See Custom Challenge for the same three callbacks used with the Edgelang challenge interface, including a CAPTCHA implementation example.