Skip to content
Guides/Elixir Plug

How to Block AI Bots on Elixir Plug: Complete 2026 Guide

Plug is Elixir's composable web middleware specification — the foundation beneath Phoenix, used directly with Bandit or Cowboy for lightweight services. A Plug module implements call/2, and stopping the pipeline requires two steps: send_resp(conn, 403, "Forbidden") followed by halt(conn) — skipping all downstream plugs and route handlers.

halt() is mandatory — send_resp alone is not enough

send_resp/3 writes the response to the conn struct but does not stop the pipeline. Without halt/1, subsequent plugs continue running and can overwrite your 403. halt/1 sets conn.halted = true. The plug macro in Plug.Builder and Plug.Router checks this flag before executing each plug — if true, the plug is skipped entirely. Always pair: send_resp(...) |> halt().

Protection layers

1
robots.txtplug Plug.Static, only: ["robots.txt"] — before the bot blocker so crawlers can always read it
2
noai meta tagIn EEx/HEEx templates or inline HTML string responses — put_resp_content_type + send_resp
3
X-Robots-Tag headerput_resp_header(conn, "x-robots-tag", "noai, noimageai") — in the bot blocker and on all non-blocked responses
4
Hard 403 — global (Plug.Builder)plug AiBotBlocker in the top-level pipeline — fires before any route matching
5
Hard 403 — scoped (Plug.Router forward)forward "/api", to: MyApp.ApiRouter with AiBotBlocker in ApiRouter's pipeline only

Step 1 — Shared bot list (lib/my_app/ai_bots.ex)

Module attribute @bots is a compile-time constant — the list is baked into the BEAM bytecode. Pattern-match ai_bot?(nil) separately so callers don't need to guard against missing headers.

# lib/my_app/ai_bots.ex — shared bot list

defmodule MyApp.AiBots do
  @moduledoc "AI bot user-agent patterns for blocking."

  @bots [
    # OpenAI
    "gptbot", "chatgpt-user", "oai-searchbot",
    # Anthropic
    "claudebot", "claude-web",
    # Common Crawl
    "ccbot",
    # Bytedance
    "bytespider",
    # Meta
    "meta-externalagent",
    # Perplexity
    "perplexitybot",
    # Google AI
    "google-extended", "googleother",
    # Cohere
    "cohere-ai",
    # Amazon
    "amazonbot",
    # Diffbot
    "diffbot",
    # AI2
    "ai2bot",
    # DeepSeek
    "deepseekbot",
    # Mistral
    "mistralai-user",
    # xAI
    "xai-bot",
    # You.com
    "youbot",
    # DuckDuckGo AI
    "duckassistbot",
  ]

  @doc "Returns true if the user-agent string matches a known AI bot."
  def ai_bot?(nil), do: false
  def ai_bot?(ua) when is_binary(ua) do
    ua_lower = String.downcase(ua)
    Enum.any?(@bots, &String.contains?(ua_lower, &1))
  end
end

Step 2 — The bot-blocking Plug module

get_req_header/2 returns a list — headers can appear multiple times. Use List.first(headers, "") to safely default to an empty string. put_resp_header/3 before send_resp/3 ensures the header appears on both blocked (403) and legitimate responses.

# lib/my_app/plugs/ai_bot_blocker.ex — the Plug module

defmodule MyApp.Plugs.AiBotBlocker do
  @moduledoc """
  Plug that blocks AI training bots with a 403.

  Usage in a Plug.Builder pipeline:
    plug MyApp.Plugs.AiBotBlocker

  Usage in a Plug.Router:
    plug MyApp.Plugs.AiBotBlocker
    get "/", do: send_resp(conn, 200, "ok")
  """

  import Plug.Conn
  alias MyApp.AiBots

  # init/1 runs at compile time — parse options here, not in call/2.
  # Returning opts unchanged is the common pattern for simple plugs.
  def init(opts), do: opts

  # call/2 runs on every request.
  # Returning conn passes through to the next plug.
  # Calling halt(conn) after send_resp stops the pipeline — no downstream
  # plugs run, including route handlers.
  def call(conn, _opts) do
    ua =
      conn
      |> get_req_header("user-agent")
      |> List.first("")  # headers return a list; default to "" if absent

    if AiBots.ai_bot?(ua) do
      conn
      |> put_resp_header("x-robots-tag", "noai, noimageai")
      |> send_resp(403, "Forbidden")
      |> halt()   # ← stops the pipeline; no handler ever runs
    else
      # Add X-Robots-Tag to all legitimate responses too
      put_resp_header(conn, "x-robots-tag", "noai, noimageai")
      # Return conn unchanged — next plug in the pipeline runs
    end
  end
end

Step 3 — Global pipeline with Plug.Builder

use Plug.Builder gives you the plug macro. Order is execution order: Plug.Static first (so robots.txt is served without hitting the bot blocker), then logging, then bot blocking, then route dispatch. The :match and :dispatch plugs are only needed if you're using Plug.Router — skip them in plain Builder.

# lib/my_app/router.ex — global pipeline with Plug.Builder

defmodule MyApp.Router do
  use Plug.Builder

  # 1. Static files first — robots.txt never hits the bot blocker.
  #    Plug.Static calls halt() itself when it serves a file.
  plug Plug.Static,
    at: "/",
    from: "priv/static",
    only: ["robots.txt", "favicon.ico"]

  # 2. Request logger (before blocking so all requests are logged)
  plug Plug.Logger

  # 3. AI bot blocker — halts the pipeline for AI bots with 403
  plug MyApp.Plugs.AiBotBlocker

  # 4. Route matching — only runs if not halted
  plug :dispatch

  def dispatch(conn, _opts) do
    case conn.request_path do
      "/" ->
        send_resp(conn, 200, "Welcome")

      "/health" ->
        send_resp(conn, 200, "ok")

      "/api/data" ->
        conn
        |> put_resp_content_type("application/json")
        |> send_resp(200, ~s({"data": "protected"}))

      _ ->
        send_resp(conn, 404, "Not Found")
    end
  end
end

Step 4 — Scoped blocking with Plug.Router and forward/2

forward "/api", to: MyApp.ApiRouter delegates all /api/* requests to a sub-router with its own pipeline — including its own bot blocker. Public routes (/health, /robots.txt) remain unprotected.

# lib/my_app/router.ex — scoped blocking with Plug.Router

defmodule MyApp.Router do
  use Plug.Router

  # Plug.Static for robots.txt — runs before everything
  plug Plug.Static,
    at: "/",
    from: "priv/static",
    only: ["robots.txt"]

  # Global pipeline — runs on all requests
  plug :match
  plug :dispatch

  # Public routes — no bot blocker
  get "/health" do
    send_resp(conn, 200, "ok")
  end

  # Forward /api/* to the protected sub-router
  # The sub-router has its own pipeline with AiBotBlocker
  forward "/api", to: MyApp.ApiRouter

  # Catch-all
  match _ do
    send_resp(conn, 404, "Not Found")
  end
end

# Protected API sub-router
defmodule MyApp.ApiRouter do
  use Plug.Router

  # Bot blocker applied only to /api/* routes
  plug MyApp.Plugs.AiBotBlocker

  plug :match
  plug :dispatch

  get "/data" do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, ~s({"data": "protected"}))
  end

  get "/users" do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, ~s([]))
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end
end

Step 5 — Application supervisor (Bandit or Cowboy)

Plug is server-agnostic. Bandit is the modern choice — pure Elixir, HTTP/2, WebSocket support, and default in Phoenix 1.7+. Cowboy remains the battle-tested option for high-throughput production deployments. Switching servers requires only a one-line change in application.ex.

# lib/my_app/application.ex — start with Bandit or Cowboy

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Option A: Bandit (pure Elixir, HTTP/1 + HTTP/2 + WebSocket)
      # mix.exs: {:bandit, "~> 1.0"}
      {Bandit, plug: MyApp.Router, port: 4000},

      # Option B: Cowboy (Erlang, battle-tested)
      # mix.exs: {:plug_cowboy, "~> 2.0"}
      # {Plug.Cowboy, scheme: :http, plug: MyApp.Router, port: 4000},
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

# mix.exs dependencies:
# defp deps do
#   [
#     {:plug, "~> 1.16"},
#     {:bandit, "~> 1.0"},       # or {:plug_cowboy, "~> 2.0"}
#     {:jason, "~> 1.4"},        # optional — JSON encoding
#   ]
# end

Step 6 — robots.txt

Place robots.txt in priv/static/. Plug.Static serves it and calls halt() itself — your bot blocker never runs for this path. Always register Plug.Static before the bot blocker in the pipeline.

# priv/static/robots.txt — place this file in your static directory
# Plug.Static serves it at GET /robots.txt automatically.

User-agent: *
Allow: /

# AI training bots — blocked
User-agent: GPTBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

User-agent: CCBot
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: Google-Extended
Disallow: /

User-agent: PerplexityBot
Disallow: /

User-agent: Meta-ExternalAgent
Disallow: /

User-agent: YouBot
Disallow: /

User-agent: AmazonBot
Disallow: /

User-agent: Diffbot
Disallow: /


# In your pipeline (Plug.Builder or Plug.Router):
# plug Plug.Static, at: "/", from: "priv/static", only: ["robots.txt"]
#
# Plug.Static must come BEFORE AiBotBlocker in the pipeline.
# It calls halt() when it serves a file, so the bot blocker
# never runs for /robots.txt requests — legitimate crawlers
# can always read it.

Step 7 — noai meta tag in EEx templates

# noai meta tag in EEx templates (without Phoenix)
# lib/my_app/templates/layout.html.eex

<!DOCTYPE html>
<html>
<head>
  <meta name="robots" content="noai, noimageai">
  <title><%= @title %></title>
</head>
<body>
  <%= @inner_content %>
</body>
</html>

# Rendering from a Plug handler:
defmodule MyApp.Router do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/" do
    html = EEx.eval_file("lib/my_app/templates/layout.html.eex",
      assigns: [title: "My Site", inner_content: "<h1>Welcome</h1>"]
    )
    conn
    |> put_resp_content_type("text/html")
    |> put_resp_header("x-robots-tag", "noai, noimageai")
    |> send_resp(200, html)
  end
end

Plug vs Phoenix Endpoint vs Plug.Router vs Cowboy

FeaturePlug (standalone)Phoenix EndpointPlug.RouterCowboy handler
Abstractioninit/1 + call/2 behaviour — composable pipeline modulesPlug inside Phoenix.Endpoint — same behaviour, Phoenix DSL on topPlug.Router — route matching DSL + same pipelinecowboy_handler behaviour (init/2 + terminate/3) — lower level
Short-circuit a requestsend_resp(conn, 403, "Forbidden") |> halt()Same — halt() works identically in Phoenix plugsSame — halt() works identically in Plug.Routercowboy_req:reply(403, #{}, <<>>, Req) — no halt() concept
Pipeline compositionPlug.Builder: plug MacroOrModule in orderPhoenix.Endpoint: plug MyModule + pipeline/pipe_throughPlug.Router: plug macro at module levelNo pipeline — single handler module per route
robots.txtplug Plug.Static, at: "/", from: "priv/static", only: ["robots.txt"]plug Plug.Static in Endpoint + Phoenix.Router scopeSame Plug.Static — place before bot blockercowboy_static handler for /robots.txt route
UA header accessget_req_header(conn, "user-agent") |> List.first("")Identical — same Plug.Conn functionsIdentical — same Plug.Conn functionscowboy_req:header(<<"user-agent">>, Req, <<>>)
Add response headerput_resp_header(conn, "x-robots-tag", "noai, noimageai")Identical — same Plug.Conn functionIdentical — same Plug.Conn functionIn headers map: #{<<"x-robots-tag">> => <<"noai, noimageai">>}
HTTP serverBandit (pure Elixir) or Cowboy (Erlang) — configurableBandit (default since Phoenix 1.7) or CowboyBandit or Cowboy — same as standalone PlugCowboy itself — no abstraction layer needed

Summary

  • send_resp + halt() — always both. send_resp alone lets the pipeline continue and overwrite your response.
  • Plug.Static before AiBotBlocker — robots.txt must be reachable by legitimate crawlers. Plug.Static halts automatically when it serves a file.
  • get_req_header returns a list — use List.first(headers, "") to safely handle missing User-Agent headers.
  • Works unchanged in Phoenix — plug AiBotBlocker in your Phoenix.Endpoint uses the exact same code.
  • Bandit or Cowboy — one-line change in application.ex. Plug middleware runs identically on both.

Is your site protected from AI bots?

Run a free scan to check your robots.txt, meta tags, and overall AI readiness score.