This guide adds Limen to a Phoenix application in dry-run mode. Nothing is blocked until you decide to enforce; see Rolling out with dry-run for that.

Install

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

Configure

Limen runs as an instance your application configures and starts. Its configuration lives in your application's environment, under the Limen key, so each application sets up its own and several instances can run side by side.

Limen signs challenge tokens and pass cookies with a secret that every node serving your site must share. Generate one:

mix phx.gen.secret 48

and configure it at runtime, together with the proxies in front of your application as CIDR ranges (an address and how many of its leading bits must match):

# config/runtime.exs
config :my_app, Limen,
  secret_key: System.fetch_env!("LIMEN_SECRET_KEY"),
  trusted_proxies: ["10.0.0.0/8", "fd00::/8"],
  client_ip_header: "x-forwarded-for"

trusted_proxies matters: forwarding headers (such as X-Forwarded-For, to which each proxy appends the address it got the request from) and the JA4 header (a fingerprint of the client's TLS handshake, see JA4 behind nginx) are only read from those peers, and forwarding chains are walked from the right, so clients cannot choose their own address. Without a load balancer, leave both options out and Limen uses the connection's peer address.

Every option is documented in Limen.Config.

Start the instance

Add Limen to your application's children, before the endpoint:

children = [
  {Limen, otp_app: :my_app},
  MyAppWeb.Endpoint
]

This starts one instance, named after your application, with its own tables and background processes. To run several (say, a stricter one for an admin area), list them under :instances; top-level options are shared:

config :my_app, Limen,
  secret_key: System.fetch_env!("LIMEN_SECRET_KEY"),
  instances: [public: [], admin: [mode: :enforce]]

Each one is then referred to by its name, as in Limen.ban(:admin, ...).

Add the plug

Put Limen.Plug in your endpoint, before Plug.Static (so asset requests count towards client behaviour) and before the router:

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  # Behind a TLS terminator, make conn.scheme reflect the client's scheme:
  # browsers only send some headers Limen checks over HTTPS.
  plug Plug.RewriteOn, [:x_forwarded_proto]

  plug Limen.Plug,
    otp_app: :my_app,
    routes: [
      {"/health", :off},
      {"/assets", :track}
    ]

  plug Plug.Static, ...
  plug MyAppWeb.Router
end

Routes pick what Limen does per path prefix: :off does nothing, :track only counts behaviour and enforces bans, and a policy module evaluates that policy (optionally with its own mode:, and instance: to use another instance for that path). Everything else uses Limen.Policy.Default unless you pass policy: MyApp.BotPolicy. With several instances, pass instance: :public instead of otp_app:.

Limen serves its challenge endpoints under /__limen. If a Content-Security-Policy (a header restricting what a page may load and run) applies to your whole site, the challenge page sets its own; it loads nothing from other origins.

Leave your users alone

Limen judges requests: a signed-in customer on a shared address looks like anyone else on it. Your application knows better, and can say so with facts, which policies read with fact/1. A trust rule allows the clients you vouch for before anything else, bans and limits included, and every such request still gets a decision that says why:

defmodule MyApp.BotPolicy do
  use Limen.Policy

  trust :signed_in, when: fact(:signed_in)
  # ... the rest of the policy
end

Facts about the user need the session, which the endpoint has not fetched yet, so gate in the router instead, after stating them, and keep a plug in the endpoint that only serves Limen's own endpoints and trap paths (the router never sees those):

# endpoint.ex, before the router
plug Limen.Plug, otp_app: :my_app, policy: :off

# router.ex
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :limen_facts
  plug Limen.Plug, otp_app: :my_app, policy: MyApp.BotPolicy
  # ...
end

defp limen_facts(conn, _opts) do
  Limen.put_facts(conn, signed_in: get_session(conn, :user_token) != nil)
end

State facts from what your application verified, never from what the client sent: a trusted client skips everything. Requests that go through no gated pipeline, such as static files, are then not evaluated; see Limen.Plug for the details.

Watch decisions

Every evaluated request emits a [:limen, :decision] telemetry event whose metadata carries the Limen.Decision. Limen.Decision.explain/1 renders it:

:telemetry.attach(
  "limen-log-challenges",
  [:limen, :decision],
  fn _event, _measurements, %{decision: decision}, _config ->
    if decision.action != :allow do
      MyApp.Metrics.increment("limen.#{decision.action}")
    end
  end,
  nil
)

Handlers run in the request process: keep them cheap. For logs, configure the sampled decision log instead, which writes structured Logger reports from a background process:

config :my_app, Limen, decision_log: [sample_rate: 0.01, non_allow_sample_rate: 1.0]

To keep sampled decisions somewhere else, such as a database table, write a Limen.DecisionLog.Sink: it gets them in batches, away from the request path.

Limen.decision(conn) returns the decision for the current request anywhere downstream of the plug.

Gate LiveView sockets

Phoenix dispatches sockets before any endpoint plug runs, so LiveView connections need their own check. See Limen.LiveView; in short:

# endpoint
socket "/live", Phoenix.LiveView.Socket,
  websocket: [connect_info: [:peer_data, :x_headers, :user_agent, :uri, session: @session_options]]

# root layout
<meta name="limen-socket" content={Limen.LiveView.token(@conn)} />

# app.js
const limen = document.querySelector("meta[name='limen-socket']")?.content
const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken, _limen: limen}})

# router
live_session :default, on_mount: {Limen.LiveView, otp_app: :my_app} do
  ...
end

For plain channel sockets, call Limen.Socket.check/3 from connect/3. Tokens are checked by the instance that issued them.

With trust rules, give the hook your policy and state the same facts for the socket in a hook that runs before it, so that trusted clients connect as they browse:

live_session :default,
  on_mount: [MyAppWeb.LimenFacts, {Limen.LiveView, otp_app: :my_app, policy: MyApp.BotPolicy}] do
  ...
end

defmodule MyAppWeb.LimenFacts do
  def on_mount(:default, _params, session, socket) do
    {:cont, Limen.LiveView.put_facts(socket, signed_in: session["user_token"] != nil)}
  end
end

Add the dashboard page

live_dashboard "/dashboard", additional_pages: [limen: {Limen.Dashboard, otp_app: :my_app}]

More than one node

Counters and limits are per node, which works well behind a load balancer. Bans should apply everywhere:

config :my_app, Limen, cluster: [enabled: true]

Instances of the same name on different nodes share their bans. See Limen.Cluster.

Next steps

  • Write your own policy: Limen.Policy.
  • Load IP-to-ASN data (which network, such as a cloud provider's, each address belongs to), and keep it current, so hosting providers can be scored: Limen.Signal.Asn.
  • Catch scrapers with hidden links and form fields, and send them to the maze: Honeypots and the maze.
  • Forward JA4 fingerprints from your TLS terminator: JA4 behind nginx.
  • Size and tune: Tuning.
  • Test your application with Limen in front of it: Testing.

Testing

Run the instance in dry-run mode in config/test.exs, so that tests not about Limen never notice it, and enforce it request by request in those that are, with Limen.Test.put_mode/2. See Testing.