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.

  • Fingerprints and behaviour: client prefixes (IPv6 aggregated to /64), JA4 TLS fingerprints from your TLS terminator (a hash of what the client's TLS library offers in the handshake), HTTP header shape against the claimed user agent, per-client request, 404 and asset patterns, hosting ASNs (the numbered networks addresses belong to, such as a cloud provider's), and search engine crawlers verified with forward-confirmed reverse DNS (the address's DNS name must belong to the crawler and resolve back to it), and AI crawlers and link previews told apart from search engines.
  • A compiled policy DSL: rules become plain functions at compile time, and every decision records which rules matched and the values they saw. Rules can use facts your application states, such as whether the client is signed in, and parameters you tune at runtime.
  • Proof-of-work challenges modelled on the Anubis proxy: the browser searches for a hash with enough leading zero bits, a fraction of a second for a visitor but a cost a scraper pays again for every identity it uses. Stateless HMAC-signed tokens (a hash only holders of the secret can compute), a vendored solver on the browser's Web Crypto API, single-use tokens, and a pass cookie checked on a fast path of a few microseconds.
  • Honeypots and a maze: hidden links and form fields catch clients that act unlike people, and send them to endless, slow, plausible pages written by a Markov chain (each word drawn from those that followed the previous two in real text), the same for your site on every visit and unpredictable anywhere else.
  • Dry-run first: every policy can observe without acting, producing exactly the decisions it would enforce.
  • Built for Phoenix: per-route policies and instances, a LiveView socket gate, cluster ban propagation over :pg (Erlang's distributed process groups), and a LiveDashboard page.

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):

PathMedian
Valid pass cookie (fast path)3.5 µs
Full evaluation with the default policy and signals4.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.