SimpleTokenAuthentication

Build Status Hex.pm Documentation Total Download License

Usage

Phoenix Integration

Inside web/router.ex file, add plug to your pipeline like so:

defmodule MyApp.Router
  use Phoenix.Router

  pipeline :api do
    plug SimpleTokenAuthentication
  end

  scope "/", MyApp do
    pipe_through :api
    get "/hello", HelloController, :hello
  end
end

Installation

  1. Add simple_token_authentication to your list of dependencies in mix.exs:
def deps do
  [{:simple_token_authentication, "~> 1.1"}]
end
  1. Ensure simple_token_authentication is started before your application:
def application do
  [applications: [:simple_token_authentication]]
end
  1. Configure your token in config.exs:
config :simple_token_authentication,
  token: "your-token-here",
  service_tokens: [
    service_a: "service-a-token",
    service_b: "service-b-token"
  ]
  1. Configure your connecting application to pass a token in the authorization header, either bare or with the Bearer scheme:
put_header("authorization", "your-token-here")
put_header("authorization", "Bearer your-token-here")

Header and scheme

By default the plug reads the authorization header and accepts either a bare token or Bearer <token> (scheme name matched case-insensitively). Both can be changed per plug:

# Require the Bearer scheme
plug SimpleTokenAuthentication, scheme: :bearer

# Only accept a bare token (behaviour of versions before 1.1)
plug SimpleTokenAuthentication, scheme: :none

# Read the token from a different header
plug SimpleTokenAuthentication, header: "x-api-key"

scheme must be one of :any (default), :bearer or :none.

Realm

Optionally, you can add a realm to the simple auth. This allows services to use the plug multiple times with different realms For example:

Configure your token under a named realm in config.exs:

config :simple_token_authentication,
  another_realm: [
    service_tokens: [
      service_a: "service-a-token",
      service_b: "service-b-token"
    ]
  ]

Pass that name when configuring the plug:

defmodule MyApp.Router
  use Phoenix.Router

  pipeline :api do
    plug SimpleTokenAuthentication, auth_realm: :another_realm
  end

  scope "/", MyApp do
    pipe_through :api
    get "/hello", HelloController, :hello
  end
end

Restricting a route to specific services

A realm decides which tokens exist; by default every service in the realm is accepted wherever the plug is used. Pass services to narrow a pipeline or controller to some of them. Tokens stay configured once, in the realm:

config :simple_token_authentication,
  partners: [
    service_tokens: [
      acme: "acme-token",
      globex: "globex-token"
    ]
  ]

For a group of routes, use a dedicated pipeline. Each scope lists its own pipelines, since pipe_through applies to every route that follows it in the scope:

pipeline :partners do
  plug SimpleTokenAuthentication, auth_realm: :partners
end

pipeline :acme_only do
  plug SimpleTokenAuthentication, auth_realm: :partners, services: [:acme]
end

scope "/", MyApp do
  pipe_through :partners
  get "/status", StatusController, :show            # acme or globex
end

scope "/acme", MyApp do
  pipe_through :acme_only
  post "/webhooks", AcmeWebhookController, :create  # acme only
end

For a single controller, put the plug in the controller instead and leave the route without an auth pipeline:

defmodule MyApp.AcmeWebhookController do
  use MyApp, :controller

  plug SimpleTokenAuthentication, auth_realm: :partners, services: [:acme]
  # or only for some actions:
  # plug SimpleTokenAuthentication, [auth_realm: :partners, services: [:acme]] when action in [:create]
end

How it behaves:

Hashed tokens

Tokens are stored in plaintext in config by default. Any token can instead be stored as its SHA-256 digest by writing it as a {:sha256, hex} tuple. This is opt-in per token: a plain string behaves exactly as before, and both forms can be mixed, including in one service's token list while rotating.

Generate the digest with SimpleTokenAuthentication.hash_token/1:

mix run -e 'IO.inspect(SimpleTokenAuthentication.hash_token("acme-secret"))'
# {:sha256, "307c609f87da43c3d563428a4f7efdf9857f4871fd10465732c4ab11a985a08c"}

Paste the tuple where the plaintext used to be:

config :simple_token_authentication,
  token: {:sha256, "307c609f87da43c3d563428a4f7efdf9857f4871fd10465732c4ab11a985a08c"},
  service_tokens: [
    acme: {:sha256, "..."},
    globex: "still-plaintext"
  ]

Clients are unaffected and keep sending the plaintext token. The plug hashes the presented value and compares digests in constant time. Presenting the digest itself does not authenticate.

Rules and limits:

Custom failure response

By default a request with no matching token gets a 401 with a JSON body and a www-authenticate: Bearer realm="<realm>" header, and a request whose service is not in services gets a 403 with a JSON body. Pass on_failure to send your own response in both cases. It receives the Plug.Conn and must send a response; the plug halts the conn afterwards.

defmodule MyApp.AuthError do
  import Plug.Conn

  def unauthorized(conn) do
    conn
    |> put_resp_content_type("application/problem+json")
    |> send_resp(401, ~s({"title": "Unauthorized"}))
  end
end

plug SimpleTokenAuthentication, on_failure: {MyApp.AuthError, :unauthorized}

A 1-arity anonymous function is also accepted, but Phoenix routers evaluate plug options at compile time, so prefer the {module, function} form there.

The same handler is used for an unknown token and for a disallowed service. To tell them apart, check conn.assigns[:simple_token_auth_service]: it is set to the matched service name on the 403 path and absent on the 401 path.

def unauthorized(%{assigns: %{simple_token_auth_service: service}} = conn) do
  send_resp(conn, 403, ~s({"title": "Forbidden", "service": "#{service}"}))
end

def unauthorized(conn) do
  send_resp(conn, 401, ~s({"title": "Unauthorized"}))
end

Telemetry

Every request through the plug emits one of two events:

Event Measurements Metadata
[:simple_token_authentication, :auth, :success] %{system_time: integer} %{realm: atom, service: atom, conn: Plug.Conn.t()}
[:simple_token_authentication, :auth, :failure] %{system_time: integer} %{realm: atom, conn: Plug.Conn.t()}, plus service: atom when rejected by services

realm is :default when no auth_realm option is given. service is the matched service name, or :global for the top-level token. On a failure event, service is present only when the token matched but the service was not in the services list. The presented token is never included.

:telemetry.attach("log-auth-failures", [:simple_token_authentication, :auth, :failure], fn _event, _measurements, %{realm: realm, conn: conn}, _ ->
  Logger.warning("auth failure in #{realm} from #{:inet.ntoa(conn.remote_ip)}")
end, nil)

Configuration errors

Misconfiguration raises an ArgumentError with a message naming the realm and the expected shape, rather than failing obscurely on the first request:

If a realm ends up with no tokens at all, a warning is logged once and every request to that realm is rejected.

Notes

Copyright (c) 2016 Podium Copyright (c) 2026 Emmanuel Pinault

This work is free. You can redistribute it and/or modify it under the terms of the MIT License. See the LICENSE.md file for more details.