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

Jun 11, 2025 - 07:40
 0
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] <- 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