Native Middleware
ePHPm can run compiled middleware in front of PHP, called per request before PHP dispatch — and before any request-body bytes are read. A rejected request (bad JWT, rate-limited client, CORS preflight) never boots PHP and never pays for the body transfer.
Middleware can call back into the host: the embedded (cluster-replicated)
KV store and the tracing logger are one function call away. That’s what
makes a cluster-wide rate limiter a ~100-line module — the replicated
counter is a single kv_incr.
There are two ways a module runs:
- Built-in (static registry). Four modules —
jwt,cors,ratelimit,security-headers— are compiled into every ePHPm binary.library = "jwt"just works: no shared library on disk, nodlopen, no special build. - Dynamic (shared library). Custom out-of-tree modules are
.so/.dylib/.dllfiles speaking a small, versioned C ABI, loaded once at startup viadlopen(LoadLibraryon Windows). This works out of the box with the stock release binaries on every platform — see the dynamic lane.
Quick start
Built-ins need nothing but configuration — this works with the stock release binary on every platform:
# /etc/ephpm/ephpm.toml
[server]
listen = "0.0.0.0:8080"
document_root = "/var/www/html"
[[middleware]]
library = "security-headers"
order = 10
config = { csp = "default-src 'self'" }
[[middleware]]
library = "cors"
order = 20
config = { allow_origins = ["https://app.example"] }
[[middleware]]
library = "jwt"
match = "/api/*"
order = 30
config = { secret = "change-me", claims_header = "X-Jwt-Claims" }
[[middleware]]
library = "ratelimit"
match = "/api/*"
order = 40
config = { per_ip_rps = 1, burst = 2 }Startup logs each module as it initialises, then the whole chain:
INFO ephpm_server::middleware: middleware initialised (builtin) module=security-headers describe=...
...
INFO ephpm_server: middleware chain loaded count=4 modules=[...]And the behavior, as observed with curl:
GET /index.php→200with the PHP body plusStrict-Transport-Security,Content-Security-Policy,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policyappended.OPTIONS /api/x.phpwithOrigin+Access-Control-Request-Methodfrom an allowed origin →204with theAccess-Control-*headers. PHP never runs, and neither do later mounts (the JWT 401 does not fire).GET /api/x.phpwithout a token →401 missing bearer token, PHP never runs. With a valid HS256 token → PHP runs and reads the verified claims from$_SERVER['HTTP_X_JWT_CLAIMS'].- Hammering
/api/x.php→ exactlyper_ip_rps × 10 + burstrequests succeed per 10-second window, then429with aRetry-After.
Configuration
Mounts are [[middleware]] blocks in ephpm.toml, ordered explicitly:
[[middleware]]
library = "security-headers" # built-in (compiled in)
order = 10
config = { csp = "default-src 'self'" }
[[middleware]]
library = "/etc/ephpm/middleware/libmy_auth.so" # custom module, explicit path
match = "/api/*"
order = 30
config = { api_key = "..." }| Key | Required | Meaning |
|---|---|---|
library | yes | Built-in name or shared library to load — bare name or explicit path (see below). Must not be empty. |
match | no | Path glob; the mount only runs when the request path matches. * matches any character sequence, including /. Unset = every PHP-bound request. |
order | yes | Chain position. Lower runs first; equal orders keep declaration order. |
config | no | Arbitrary table, serialised to JSON and handed to the module’s init. |
Mounts are global — they apply to every vhost. A module that needs per-tenant behavior reads the request’s vhost id (the server name) and decides itself.
Loading is fail-fast: a builtin whose init rejects its config, a
library that can’t be found, a missing ABI symbol, or a dynamic module
whose init returns an error aborts server startup with a message naming
the mount.
Library resolution
The library value is checked against the builtin registry first.
Each built-in answers to its short name and its crate name, with - and
_ interchangeable: jwt, cors, ratelimit (also rate-limit),
security-headers, and the ephpm-middleware-* / ephpm_middleware_*
long forms. Builtin mounts never touch the filesystem.
Anything else is resolved as a shared library. A value containing a path separator or a file extension is used as-is. A bare name tries, in each search directory:
<name>.<os>-<arch>.<ext>— e.g.my-auth.linux-x86_64.solib<name>.<ext>— cargo’s own artifact naming<name>.<ext>
Search directories, in order:
- the server’s working directory
$EPHPM_MIDDLEWARE_DIR(when set)/usr/local/lib/ephpm/middleware
The startup error lists every candidate path tried, so a typo’d mount is easy to diagnose.
Chain semantics (v1)
Per request, the chain walks mounts in ascending order, skipping mounts
whose match doesn’t match the request path. Each module returns one of
three verdicts:
- CONTINUE — keep walking; optionally append headers to the eventual client response (CORS headers, security headers).
- RESPOND — short-circuit immediately: the module’s status/body/headers go back to the client and PHP never runs. Later mounts don’t run either.
- REWRITE — accumulate a request-path override (last writer wins) and/or request-header overrides (chain order), then keep walking. May also append response headers, like CONTINUE.
v1 rules worth knowing:
- Every module sees the original request. Rewrites are applied after the whole chain ran — a later module does not observe an earlier module’s path/header overrides.
- Header overrides reach PHP as normal request headers (
HTTP_*in$_SERVER), replacing any client-sent header of the same name — that’s howjwt’sclaims_headerhands verified claims to PHP. - A path rewrite affects
REQUEST_URI(andPATH). In fpm mode the script was already resolved before the chain ran, so the originally resolved script still executes; in worker mode the framework routes on the rewrittenREQUEST_URI, so rewrites fully re-route. - Failures are fail-closed. A dynamic module whose
invokereturns non-zero, a Rust panic caught by the authoring kit, or a panicking built-in (caught by the host) all produce a plain 500, never a silent pass-through. - Request bodies are not visible to middleware. The chain runs before the body is read (rejecting before the transfer is the point); the ABI’s body accessor currently always returns length 0.
Coverage. In fpm mode the chain runs on PHP-dispatched requests only: static-file responses and router error responses (403/404) do not pass through middleware. If you need a rule (rate limit, auth, security headers) to cover static assets or error pages under fpm mode, enforce it in front of ePHPm. In worker mode every request is routed through PHP, so the chain sees everything — static, dynamic, and error paths alike.
The built-in modules
All four are compiled into every ePHPm binary and run in-process — the sections below apply identically whether you mount them by short name (built-in) or dlopen their cdylib builds on a dynamic binary.
security-headers
Always CONTINUEs; the configured headers ride along on whatever response PHP produces for every matching request. All config keys optional:
| key | default | header |
|---|---|---|
hsts (bool) | true | Strict-Transport-Security: max-age=63072000; includeSubDomains |
csp (string) | unset | Content-Security-Policy |
frame_options (string) | "DENY" | X-Frame-Options (empty string disables) |
content_type_options (bool) | true | X-Content-Type-Options: nosniff |
referrer_policy (string) | "strict-origin-when-cross-origin" | Referrer-Policy (empty string disables) |
cors
Answers CORS preflights directly (204, PHP never runs) and appends
Access-Control-Allow-Origin / Vary: Origin to actual cross-origin
responses. Requests without an Origin header, or from a disallowed origin,
pass through untouched (per spec, the browser enforces the failure).
| key | default | meaning |
|---|---|---|
allow_origins (array) | required | allowed origins; "*" allows all |
allow_methods (string) | "GET, POST, PUT, PATCH, DELETE, OPTIONS" | preflight Access-Control-Allow-Methods |
allow_headers (string) | "Content-Type, Authorization" | preflight Access-Control-Allow-Headers |
allow_credentials (bool) | false | emit Access-Control-Allow-Credentials: true and echo the origin instead of * |
max_age (integer) | 86400 | preflight Access-Control-Max-Age seconds |
jwt
Validates HS256 bearer tokens before PHP runs. Missing/invalid tokens
short-circuit with 401. The signature is verified first (constant-time
HMAC), alg is pinned to HS256 (alg: none is rejected), exp is
required and must be in the future, nbf is honoured, and iss/aud
are enforced when configured.
| key | default | meaning |
|---|---|---|
secret (string) | required | HS256 shared secret |
issuer (string) | unset | required iss claim value |
audience (string) | unset | required aud value (string or array member) |
header (string) | "Authorization" | request header carrying the token; Bearer prefix stripped |
claims_header (string) | unset | forward the verified claims JSON to PHP in this request header |
With claims_header = "X-Jwt-Claims", PHP reads the verified claims from
$_SERVER['HTTP_X_JWT_CLAIMS'] without re-verifying the token. Any
client-sent header of that name is stripped at ingest — before the
middleware chain runs and before any header crosses to PHP — so a request
that never matches this module’s match glob (or bypasses it entirely) can
never smuggle a forged claims value through. When a valid token is present
the jwt module then sets the header to the verified claims JSON. PHP can
therefore trust HTTP_X_JWT_CLAIMS regardless of request path.
ePHPm also always strips the
Proxyrequest header at ingest (httpoxy defense), so it never surfaces as$_SERVER['HTTP_PROXY'].
v1 is HS256 only — RS256/JWKS is not implemented.
ratelimit
Fixed-window per-client rate limiting backed by the embedded KV store.
Requests are counted in 10-second windows; each window allows
per_ip_rps × 10 + burst requests per client. Over the limit: 429 with
Retry-After for the seconds left in the window.
Cluster scope: per-node only, not cluster-wide (v1). The counter is
maintained with KV INCR, which is not yet gossip-replicated across
nodes — only SET/DEL writes propagate. That means each node enforces
its own window independently: a client hitting N nodes gets up to N ×
the configured allowance. A cluster-wide window is planned (issue #150),
tracked with replicated INCR. Startup logs a warn! when ratelimit
is mounted with [cluster].enabled = true so operators see the gap.
| key | default | meaning |
|---|---|---|
per_ip_rps (integer) | required, > 0 | sustained requests/second per client |
burst (integer) | per_ip_rps | extra headroom per window |
key_headers (array) | unset | identify clients by the first present header (e.g. X-Api-Key) instead of client IP |
Fail-open by design: if the KV store is unavailable, requests are allowed through with a warning log — a rate limiter that hard-fails would turn a soft protection into an outage. Don’t use it as an auth gate.
Note this is a fixed-window limiter (a full window’s allowance can be
consumed instantly at a window boundary), and it is distinct from the
built-in connection-level limiter in [server.limits] — the two are
independent.
The dynamic lane
Custom out-of-tree modules load through dlopen (LoadLibrary on
Windows). This works with the stock release binaries on every platform:
- Linux release binaries (
cargo xtask release, thedocker/Dockerfileimage, and the published release artifacts) are glibc-dynamic — a single file that targets<arch>-unknown-linux-gnuand candlopen()shared middleware (and shared PHP extensions — see PHP Extensions) out of the box. - macOS release binaries are dynamically linked against the system
runtime (
dlopenis always available there). - Windows builds use
LoadLibrary— no special build needed.
The module’s libc must match the host binary’s: on Linux, build modules for the gnu target (the default on every mainstream distro toolchain) — see Building modules on Linux.
Building a fully static binary yourself: you can still produce a
fully static musl ePHPm (x86_64-unknown-linux-musl with crt-static)
if your deployment demands it, but be aware that a fully static binary
cannot dlopen() anything — every [[middleware]] mount that
resolves to a shared library (and every [php] extensions entry) fails
startup with Dynamic loading not supported. Built-ins keep working;
custom static composition tooling for that scenario is future work
(docs/architecture/build-compose-design.md). Planned — not yet
implemented.
Writing your own module in Rust
Add the authoring crate and implement one trait:
[package]
name = "my-auth"
[lib]
crate-type = ["cdylib"]
[dependencies]
ephpm-middleware = { git = "https://github.com/ephpm/ephpm" }
serde_json = "1"use ephpm_middleware::{declare, Middleware, Request, Response};
struct MyAuth { api_key: String }
impl Middleware for MyAuth {
fn init(config: &serde_json::Value) -> Result<Self, String> {
let api_key = config.get("api_key")
.and_then(|v| v.as_str())
.ok_or("`api_key` is required")?;
Ok(Self { api_key: api_key.to_owned() })
}
fn invoke(&self, req: &Request<'_>) -> Response {
match req.header("X-Api-Key") {
Some(k) if k == self.api_key => Response::cont(),
_ => Response::respond(401, "nope"),
}
}
}
declare!(MyAuth);declare! generates the four C ABI exports, the ABI major-version check,
config JSON parsing, response marshaling, and panic containment (a panicking
invoke becomes a fail-closed 500).
Inside invoke, req.host() exposes host services:
let host = req.host();
host.kv_set("k", b"v", 60); // TTL in seconds; 0 = no expiry
let v = host.kv_get("k"); // Option<Vec<u8>>
let created = host.kv_set_nx("k", b"0", 30);
let n = host.kv_incr("counter", 1); // Option<i64>, atomic
host.log(ephpm_middleware::abi::LOG_INFO, "hello from middleware");The KV operations hit the same embedded store PHP sees through
ephpm_kv_* — replicated across the cluster when clustering is enabled.
Building modules on Linux
The module must match the host binary’s libc. The release binary is
glibc-dynamic (gnu target), so a plain release build on any mainstream
distro produces a compatible .so:
cargo build --release -p my-authThe artifact lands at target/release/lib<crate_name>.so; a bare
library = "<crate_name>" mount finds the lib<name>.so form through
the search path. The four in-tree modules build exactly the same way
(-p ephpm-middleware-jwt -p ephpm-middleware-cors -p ephpm-middleware-ratelimit -p ephpm-middleware-security-headers).
Build on a distro whose glibc is not newer than the deployment target’s (the usual glibc forward-compatibility rule — a module built on Debian 12 runs on anything with glibc >= Debian 12’s).
The C ABI (for non-Rust modules)
A module is any shared library exporting:
int32_t ephpm_middleware_init(uint32_t abi_version,
const char* config_json,
const ephpm_host_v1* host);
int32_t ephpm_middleware_invoke(const ephpm_request_t* request,
ephpm_response_t* response_out);
void ephpm_middleware_shutdown(void);
const char* ephpm_middleware_describe(void); /* optional, nullable */abi_versionis0x01_00_00_00for v1; the major byte gates compatibility. Modules must refuse to init (return non-zero) when the host’s major is newer than they were built for.config_jsonis the mount’sconfigtable serialised to JSON (NULL when the mount has no config).- The host callback table is passed by pointer at
initand is valid for the process lifetime — modules do notdlsymhost symbols (that would need-rdynamicon Linux and has no clean Windows analogue). It contains request accessors (method, path, query, remote IP, header lookup, vhost id), the KV operations (kv_get/kv_set/kv_set_nx/kv_incr/kv_free) andlog. - The request pointer is only valid during
invoke; never store it. Everything a module writes intoresponse_outmust stay valid until itsinvokereturns — the host copies before unwinding. - New host capabilities append to the end of the table under the same major version.
The authoritative definition is
crates/ephpm-middleware/src/abi.rs.
Observability
Each module invocation increments
ephpm_middleware_invocations_total{module, action} where action is the
verdict (continue / respond / rewrite; module errors count as
respond since they fail closed as 500s). Module log calls surface
through the host’s tracing subscriber under the ephpm_middleware
target.
Trust model
Middleware runs in-process with the same privileges as ePHPm itself.
There is no sandbox: a buggy module can crash the server; a malicious one
owns it. Only load modules you built or trust — treat a .so mount like a
binary you’re executing, because it is. (Rust-authored modules get panic
containment from declare!, but that is not a security boundary — a
memory fault is not a panic and is not contained.)
When a module does fault, ePHPm writes a fatal-signal report to stderr
naming the faulting .so and function before it dies — see
Diagnosing Crashes.
Not implemented (yet)
Planned — not yet implemented: request-body access from middleware, an
async invoke variant, hot reload of modules, per-vhost mounts, a WASM
loader for sandboxed modules, and the wider module catalog (basic-auth,
IP lists, webhook signatures, GeoIP, response cache, OpenTelemetry,
request-id). The design notes live in the git history of the roadmap page
this guide replaced.