SimpleTokenAuthentication
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
- Add
simple_token_authenticationto your list of dependencies inmix.exs:
def deps do
[{:simple_token_authentication, "~> 1.1"}]
end
- Ensure
simple_token_authenticationis started before your application:
def application do
[applications: [:simple_token_authentication]]
end
- 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"
]
- Configure your connecting application to pass a token in the
authorizationheader, either bare or with theBearerscheme:
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:
- A
globextoken on an acme-only route is recognised but rejected with a403and the body{ "error": "Service not allowed" }. Nowww-authenticateheader is sent, since the caller did authenticate. - A token that matches nothing still gets the usual
401. - Omitting
serviceskeeps today's behaviour: any token in the realm is accepted. - The list can name several services, e.g.
services: [:acme, :initech]. - With the default realm,
:globalnames the top-leveltoken, soservices: [:global]admits only that token andservices: [:reporting]admits only that service token. servicesmust be a non-empty list of atoms; anything else raises when the plug is initialised. A name that is not configured in the realm is not an error, it just never matches.
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:
- The digest must be 64 hex characters, upper or lower case, and only
:sha256is supported. Anything else raises anArgumentErrornaming the service and realm when the realm is first used. - This protects the token at rest in config files, config dumps and the running VM. It does not protect it in transit (use TLS), on the client, or against guessing.
- The hash is unsalted. Use long random tokens, for example
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false). A short or guessable token is as weak hashed as it is in plaintext.
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:
auth_realmmust be an atom (checked when the plug is initialised)services, when given, must be a non-empty list of atoms (checked when the plug is initialised)- a realm named in
auth_realmmust be configured as a keyword list with aservice_tokenskey service_tokensmust be a keyword list of service name to token- every token must be a string, a
{:sha256, hex_digest}tuple, or a list of those - a hashed token must use
:sha256and be a 64 hex character digest
If a realm ends up with no tokens at all, a warning is logged once and every request to that realm is rejected.
Notes
- Token value can be a comma-separated list of tokens
- Any token can be stored as a
{:sha256, hex}digest instead of plaintext, see "Hashed tokens" - Specifying
service_tokensis optional - Auth will succeed if token exists in either list (
tokenorservice_tokens) - Use of a service token will add "service_name" to
Logging.metadata - Service can be identified in the conn.assigns[:simple_token_auth_service]. Will be the name of the service or :global when matching the token key
- Pass
services: [...]to a plug call to accept only some of the realm's services on that route
Copyright and License
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.