Locking the Gate: Secure and Scalable API Authentication in Phoenix
Every API starts with trust. And trust is earned at the boundary. You can build fast endpoints. Return beautiful JSON. Document your data contracts. But if anyone can call it without proving who they are? You don’t have a platform. You have an open invitation to abuse, leaks, and chaos. Authentication isn’t a bolt-on. It’s a first principle. And in Phoenix, you have the tools to get it right — if you start with clarity. Phoenix Doesn’t Prescribe — It Empowers Phoenix doesn’t force cookies, JWTs, sessions, or OAuth. Instead, it gives you full control over the request lifecycle. That flexibility is power — and responsibility. For APIs, Sessions Don’t Belong You’re not rendering views. You’re serving: Mobile apps Frontend SPAs Embedded clients Third-party tools These speak one language: HTTP + Bearer token in the Authorization header Step 1: Plug Authentication at the Router Start in your router. Define an authenticated pipeline: pipeline :api_auth do plug :accepts, ["json"] plug MyAppWeb.Plugs.Authenticate end scope "/api/v1", MyAppWeb do pipe_through :api_auth get "/me", UserController, :show end Step 2: Implement the Authenticate Plug This plug extracts and validates the token: defmodule MyAppWeb.Plugs.Authenticate do import Plug.Conn def init(opts), do: opts def call(conn, _opts) do with ["Bearer " token] send_resp(401, "Unauthorized") |> halt() end end end JWT? Decode and verify claims. Opaque token? Look it up hashed in the DB. Once verified, assign to conn.assigns.current_user — and you're done. Step 3: Keep Controllers Clean Because the plug handles auth, your controllers can stay focused: def show(conn, _params) do user = conn.assigns.current_user render(conn, "user.json", user: user) end No token logic here. Just business logic. JWT vs. Database Tokens JWTs ✅ Stateless ✅ Fast to verify ⚠️ Can’t revoke easily ⚠️ Can’t track usage DB Tokens ✅ Revocable ✅ Traceable ✅ Rotatable ⚠️ Requires storage and lookup Pick what fits. Control vs. convenience. Scope & Role-Based Access Token validity is just the start. Ask: What does this token authorize? Admins ≠ Regular users 3rd-party clients ≠ Internal services Mobile apps ≠ Browsers Centralize access logic: Define roles and scopes Create helpers: def require_admin(conn, _opts) do if conn.assigns.current_user.role != "admin" do conn |> send_resp(403, "Forbidden") |> halt() else conn end end Use macros or plugs: plug :require_admin when action in [:delete, :update] Observability: Audit Everything If you authenticate requests, log them. Logger.metadata(user_id: user.id, scope: "read:reports") Logger.info("API request to /api/v1/reports") Log: user_id request path origin IP scopes used

Every API starts with trust.
And trust is earned at the boundary.
You can build fast endpoints. Return beautiful JSON. Document your data contracts.
But if anyone can call it without proving who they are?
You don’t have a platform. You have an open invitation to abuse, leaks, and chaos.
Authentication isn’t a bolt-on. It’s a first principle.
And in Phoenix, you have the tools to get it right — if you start with clarity.
Phoenix Doesn’t Prescribe — It Empowers
Phoenix doesn’t force cookies, JWTs, sessions, or OAuth.
Instead, it gives you full control over the request lifecycle.
That flexibility is power — and responsibility.
For APIs, Sessions Don’t Belong
You’re not rendering views. You’re serving:
- Mobile apps
- Frontend SPAs
- Embedded clients
- Third-party tools
These speak one language:
HTTP + Bearer token in the Authorization
header
Step 1: Plug Authentication at the Router
Start in your router. Define an authenticated pipeline:
pipeline :api_auth do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.Authenticate
end
scope "/api/v1", MyAppWeb do
pipe_through :api_auth
get "/me", UserController, :show
end
Step 2: Implement the Authenticate
Plug
This plug extracts and validates the token:
defmodule MyAppWeb.Plugs.Authenticate do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, user} <- MyApp.Auth.verify_token(token) do
assign(conn, :current_user, user)
else
_ -> conn |> send_resp(401, "Unauthorized") |> halt()
end
end
end
- JWT? Decode and verify claims.
- Opaque token? Look it up hashed in the DB.
Once verified, assign to conn.assigns.current_user
— and you're done.
Step 3: Keep Controllers Clean
Because the plug handles auth, your controllers can stay focused:
def show(conn, _params) do
user = conn.assigns.current_user
render(conn, "user.json", user: user)
end
No token logic here. Just business logic.
JWT vs. Database Tokens
JWTs
✅ Stateless
✅ Fast to verify
⚠️ Can’t revoke easily
⚠️ Can’t track usage
DB Tokens
✅ Revocable
✅ Traceable
✅ Rotatable
⚠️ Requires storage and lookup
Pick what fits. Control vs. convenience.
Scope & Role-Based Access
Token validity is just the start.
Ask: What does this token authorize?
- Admins ≠ Regular users
- 3rd-party clients ≠ Internal services
- Mobile apps ≠ Browsers
Centralize access logic:
- Define roles and scopes
- Create helpers:
def require_admin(conn, _opts) do
if conn.assigns.current_user.role != "admin" do
conn |> send_resp(403, "Forbidden") |> halt()
else
conn
end
end
- Use macros or plugs:
plug :require_admin when action in [:delete, :update]
Observability: Audit Everything
If you authenticate requests, log them.
Logger.metadata(user_id: user.id, scope: "read:reports")
Logger.info("API request to /api/v1/reports")
Log:
user_id
request path
origin IP
scopes used