Limen

Limen (Latin): threshold. The point every request crosses before it enters.

Limen is native Elixir L7 bot protection: a Plug that classifies requests, rate-limits, challenges and blocks automated traffic entirely inside the BEAM. No sidecar, no external service: all state lives in ETS, :atomics and :persistent_term, and nothing on the request path calls a process or sends a message.

Quick start

def deps do
[
{:limen, "~> 0.2"}
]
end

Configure Limen in your application's environment, start an instance in your supervision tree, and add the plug to your endpoint, before the router:

# config/runtime.exs
config :my_app, Limen,
secret_key: System.fetch_env!("LIMEN_SECRET_KEY"),
trusted_proxies: ["10.0.0.0/8"],
client_ip_header: "x-forwarded-for"
# lib/my_app/application.ex, before the endpoint
children = [{Limen, otp_app: :my_app}, MyAppWeb.Endpoint]
# lib/my_app_web/endpoint.ex, before Plug.Static and the router
plug Limen.Plug,
otp_app: :my_app,
routes: [
{"/health", :off},
{"/assets", :track}
]

Limen starts in dry-run mode with Limen.Policy.Default: every decision is computed, emitted as telemetry and sampled into the decision log, but nothing is blocked until you switch to :enforce. See the getting started guide and the dry-run rollout guide.

Each application configures and starts its own instances, so several can run side by side: one per endpoint, a stricter one for some routes, or one per test. The request path finds its instance with a single :persistent_term read.

A policy

defmodule MyApp.BotPolicy do
use Limen.Policy
limit :flood, key: :prefix, rate: 50, per: :second, burst: 100
allow :verified_crawler, when: signal(:fcrdns) == :verified
deny :known_bad_ja4, when: signal(:ja4) in list(:bad_ja4), ban: 3_600
score :no_accept_language, 20, when: missing_header("accept-language")
score :datacenter_asn, 30, when: signal(:asn_kind) == :hosting
score :spoofed_browser, 40, when: shape_flag(:no_client_hints)
score :burst, 40, when: rate(:prefix, per: :second) > 20
decide do
score >= 80 -> :deny
score >= 40 -> {:challenge, difficulty: difficulty_for(score)}
true -> :allow
end
end

Referencing a signal no configured signal module provides is a compile error. Every decision explains itself:

challenge(difficulty: 16) (enforced) at stage decide by MyApp.BotPolicy, score 70
prefix: 203.0.113.0/24
score datacenter_asn +30 when signal(:asn_kind) == :hosting [signal(:asn_kind) = :hosting]
score burst +40 when rate(:prefix, per: :second) > 20 [rate(:prefix, per: :second) = 35]
decided by: score >= 40 -> {:challenge, difficulty: difficulty_for(score)}
signal asn = 16509 ...

How a request is handled

request
│
▼
Limen.Plug
├── Identify client address behind trusted proxies, prefix, JA4
├── Trust? a trust rule on the application's facts → allow
├── Trap? trap path → flag the prefix, maze
├── Ban? banned prefix → deny, or maze
├── Limit GCRA hard limits → throttle
├── Pass? valid pass cookie → allow (fast path)
├── Collect signals → %Limen.Context{}
├── Score policy rules → score + matched rules
├── Decide allow | challenge(difficulty) | throttle | deny | tarpit | maze
└── Act respond or continue, emit telemetry

Hard limits use GCRA, the generic cell rate algorithm: a leaky bucket that stores a single timestamp per key.

Performance

Median cost per request on an Apple M4 Pro (see bench/README.md):

Path Median
Valid pass cookie (fast path) 3.5 µs
Full evaluation with the default policy and signals 4.6 µs

Benchmarks run in CI on every pull request, against the base branch on the same runner, and fail the build on a regression over 10%. State is bounded: a flood of a million unique IPv6 /64 prefixes leaves memory where it was after a hundred thousand.

Try it

elixir examples/demo.exs starts a small Phoenix site with Limen in front of it: a LiveView form, a lab that triggers every kind of decision and shows Limen's telemetry as it happens, and LiveDashboard with Limen's page and charts. It starts in enforce mode; LIMEN_MODE=dry_run starts it in dry-run.

Guides

Verification

mix precommit runs every check CI runs, in one command.

Besides unit, property and doctest suites, CI runs the vendored solver under Node.js against the server, floods the state layer with a million unique prefixes, and propagates bans between two nodes. A real-browser test (mix test --only browser) drives Chrome or Firefox through the challenge. Static checks include credo --strict with most opt-in checks, a custom check that forbids process messaging outside background processes, boundary layering, dialyzer and docs built with warnings as errors.

Licensing

Limen is Apache-2.0 licensed. JA4 (the TLS client fingerprint) is BSD-3-Clause; other JA4+ methods have been published under the more restrictive FoxIO License, and Limen implements none of them. The HTTP shape signal is an independent design.

License

Apache-2.0. See LICENSE.