Centinel AnalyticaCentinel Analytica
PlatformsWeb Server

Apache HTTP Server

Bot protection for Apache HTTP Server using a Lua module with mod_lua.

Overview

Centinel protects Apache HTTP Server using a Lua module loaded via mod_lua. An access checker hook intercepts every request, validates it against the Centinel API, and enforces the returned decision before your application logic runs.

Prerequisites

  • Secret key from your dashboard
  • Apache 2.4+ with mod_lua enabled
  • Lua 5.1 libraries: lua-curl, lua-socket, lua-cjson

Debian/Ubuntu users

The lua-curl package from apt does not work with mod_lua. It installs, but the shared object file that require("lcurl") needs is never created. Follow the luarocks install method in the Debian/Ubuntu tab below.

Install

Download and extract the module

Download centinel-apache.zip and extract both files to your Lua module directory:

curl -O https://docs.centinelanalytica.com/downloads/centinel-apache.zip
unzip centinel-apache.zip -d /usr/local/lib/lua/

The zip contains:

  • centinel-apache.lua - core module
  • centinel-handler.lua - Apache hook entry point
Install dependencies

On Alpine Linux:

apk add apache2-lua lua5.1-curl lua5.1-socket lua5.1-cjson

On Debian/Ubuntu:

apt-get install apache2 lua-socket lua-cjson luarocks libcurl4-openssl-dev lua5.1-dev
a2enmod lua
luarocks install Lua-cURLv3 LUA_VERSION=5.1 CURL_INCDIR=/usr/include

Why luarocks instead of apt for lcurl

The Debian lua-curl apt package does not install the lcurl.so shared object that mod_lua loads via require("lcurl"). luarocks builds it directly. The LUA_VERSION=5.1 flag is required because luarocks compiles against Lua 5.3/5.4 headers by default, but mod_lua uses Lua 5.1. Without it, Apache fails to load the module with undefined symbol: lua_tointeger.

Configure httpd.conf
# Load mod_lua
LoadModule lua_module modules/mod_lua.so

# Lua package paths (adjust for your distro)
LuaPackagePath /usr/share/lua/5.1/?.lua
LuaPackagePath /usr/share/lua/5.1/?/init.lua
LuaPackageCPath /usr/lib/lua/5.1/?.so

# Centinel module path
LuaPackagePath /usr/local/lib/lua/?.lua

# Pass environment variables to Lua
PassEnv CENTINEL_SECRET_KEY
PassEnv CENTINEL_VALIDATOR_URL
PassEnv CENTINEL_DEBUG

# Persist Lua state across requests (required for connection reuse)
LuaScope server

# Centinel access checker — runs on every request
LuaHookAccessChecker /usr/local/lib/lua/centinel-handler.lua access_check early

LuaScope server is required

Without LuaScope server, Apache creates a fresh Lua state per request. The module reuses a persistent HTTP/2 connection to the validator API. Dropping that state on every request forces a new TLS handshake per validation.

Set your secret key and restart
export CENTINEL_SECRET_KEY="sk_live_your_key_here"
apachectl configtest && apachectl restart

For systemd:

[Service]
Environment="CENTINEL_SECRET_KEY=sk_live_your_key_here"

Docker

FROM alpine:3.21

RUN apk add --no-cache \
    apache2 \
    apache2-lua \
    apache2-proxy \
    lua5.1-curl \
    lua5.1-socket \
    lua5.1-cjson \
    ca-certificates

RUN mkdir -p /run/apache2

RUN wget -q -O /tmp/centinel-apache.zip \
    https://docs.centinelanalytica.com/downloads/centinel-apache.zip && \
    unzip /tmp/centinel-apache.zip -d /usr/local/lib/lua/ && \
    rm /tmp/centinel-apache.zip

COPY httpd.conf /etc/apache2/httpd.conf

EXPOSE 80
CMD ["httpd", "-D", "FOREGROUND", "-f", "/etc/apache2/httpd.conf"]
docker run -e CENTINEL_SECRET_KEY="sk_live_xxx" -p 80:80 your-image

Configure

All paths are protected by default except static assets. Override in centinel.init():

centinel.init({
    secret_key = os.getenv("CENTINEL_SECRET_KEY"),
    protected_paths = { "^/api/", "^/admin" },  -- empty = protect all
    unprotected_paths = { "^/health$", "^/metrics$" }
})

Excluded by default: images (.gif, .ico, .jpg, .jpeg, .png, .svg, .webp, .avif, .bmp), fonts (.eot, .otf, .ttf, .woff, .woff2), styles (.css, .less), scripts (.js, .map), media (.mp3, .mp4, .webm, .wav, .flac, and others), archives (.gz, .zip), and .json, .xml.

centinel.init({
    secret_key = os.getenv("CENTINEL_SECRET_KEY"),
    timeout_ms = 500,          -- default: 500
    connect_timeout_ms = 300,  -- default: 300
})

This module always fails open

If the validator is unreachable, slow, or returns an error, the request is allowed through. There is no fail-closed mode and no configurable backoff. If you need requests denied during a validator outage, use a different integration.

centinel.init({
    secret_key = os.getenv("CENTINEL_SECRET_KEY"),
    debug = true
})

Or set CENTINEL_DEBUG=true as an environment variable (requires PassEnv CENTINEL_DEBUG in httpd.conf).

Check the error log for [Centinel] entries:

tail -f /var/log/apache2/error.log | grep Centinel

Replace centinel-handler.lua with your own handler to control initialization directly:

local centinel = require("centinel-apache")

local initialized = false

function access_check(r)
    if not initialized then
        centinel.init({
            secret_key = r.subprocess_env["CENTINEL_SECRET_KEY"]
                or os.getenv("CENTINEL_SECRET_KEY"),
            validator_url = os.getenv("CENTINEL_VALIDATOR_URL")
                or "https://validator.centinelanalytica.com/validate",
            timeout_ms = 500,
            debug = false,
            protected_paths = { "^/api/", "^/admin" },
            unprotected_paths = { "^/health$", "^/metrics$" }
        })
        initialized = true
    end

    return centinel.access_handler(r)
end

Point LuaHookAccessChecker to your custom handler file.

Configuration reference

OptionTypeDefaultDescription
secret_keystringAPI key from the dashboard. Required.
validator_urlstringhttps://validator.centinelanalytica.com/validateValidator endpoint.
timeout_msnumber500Request timeout (ms).
connect_timeout_msnumber300Connection timeout (ms).
ssl_verifybooleantrueVerify SSL certificates.
debugbooleanfalseVerbose logging.
log_enabledbooleantrueEnable all logging.
protected_pathstable{}Lua patterns to protect. Empty = protect all.
unprotected_pathstable[static assets]Lua patterns to skip.

Environment variables

VariableDescription
CENTINEL_SECRET_KEYAPI key. Required.
CENTINEL_VALIDATOR_URLCustom validator endpoint.
CENTINEL_DEBUGSet to true or 1 for debug logging.

All three require PassEnv directives in httpd.conf to be accessible from Lua.

Verify

Reload Apache, then send a request that should be denied:

curl -s -o /dev/null -w '%{http_code}\n' -A 'python-requests/2.31' http://localhost/api/test
tail -f /var/log/apache2/error.log | grep Centinel

A 403 means enforcement is live. A 200 with nothing in the error log usually means the module never loaded, or a Lua dependency is missing. See Troubleshooting below.

Troubleshooting

module 'lcurl' not found

The lua-curl apt package on Debian/Ubuntu installs without producing lcurl.so, so require("lcurl") fails. Install via luarocks instead:

apt-get install luarocks libcurl4-openssl-dev lua5.1-dev
luarocks install Lua-cURLv3 LUA_VERSION=5.1 CURL_INCDIR=/usr/include

undefined symbol: lua_tointeger

Full error:

error loading module 'lcurl' from file '/usr/local/lib/lua/5.1/lcurl.so':
    /usr/local/lib/lua/5.1/lcurl.so: undefined symbol: lua_tointeger

luarocks compiled lcurl against Lua 5.3 or 5.4 headers, but mod_lua uses Lua 5.1. Reinstall with the correct target:

luarocks remove Lua-cURLv3
luarocks install Lua-cURLv3 LUA_VERSION=5.1 CURL_INCDIR=/usr/include

Module loads but requests time out or fail

Check that LuaScope server is set in httpd.conf. Without it, Apache creates a fresh Lua state per request, forcing a new TLS handshake to the validator on every hit. That causes severe latency and request timeouts under load.

LuaScope server

Protection appears to do nothing

The module loads its dependencies defensively, so a missing Lua library disables protection rather than erroring. Both failures are silent in normal operation:

  • Without lcurl, no validator call is made and every request is allowed. Look for [Centinel] HTTP backend in the error log.
  • Without mime (from luasocket), base64 decoding always fails, so every interstitial challenge turns into an allow. Blocks still serve a generic fallback page.

Check the module loaded and both libraries resolved:

grep -i version /usr/local/lib/lua/centinel-apache.lua
tail -f /var/log/apache2/error.log | grep Centinel

mod_lua can set a response header but not append to it, so the module emits at most one Set-Cookie and logs the rest as dropped. If a challenge needs more than one cookie, this platform cannot deliver it.

How to check logs

Enable debug logging and tail the Apache error log:

tail -f /var/log/apache2/error.log | grep Centinel

Enable debug mode via config:

centinel.init({
    secret_key = os.getenv("CENTINEL_SECRET_KEY"),
    debug = true
})

Or via environment variable (requires PassEnv CENTINEL_DEBUG in httpd.conf):

export CENTINEL_DEBUG=true

Changelog

  • v1.2.0 — Failover backoff fix
  • v1.1.1 — Response header passthrough
  • v1.1.0 — HTTP/2 via lcurl
  • v1.0.0 — Initial release

On this page