Adding a real OAuth 2.0 server to a Phoenix app with boruta

Luca ·

SlashFeed started as a single-user RSS reader with phx.gen.auth providing email/password and magic-link login. That covered the human use case. Then the API grew: a CLI, a few personal scripts, an MCP server for AI agents, and eventually the desire to let me sign in to other tools using my SlashFeed account.

At that point session cookies aren’t enough. I needed an actual OAuth 2.0 authorization server, and — because I wanted “Sign in with SlashFeed” to be a real thing — OpenID Connect on top of it.

I didn’t want to build that from scratch. OAuth is one of those specs that’s deceptively simple to read and surprisingly easy to get wrong in security-relevant ways. So I went looking for an Elixir-native solution, and landed on boruta_auth.

This post is about how I dropped boruta into a Phoenix app that already had its own user model, what it gave me for free, where I had to write glue code, and how I tested the whole thing.

What I wanted

Concrete requirements, in order of priority:

  1. Authorization Code + PKCE for interactive clients (CLI, third-party apps, future mobile)
  2. Client Credentials for scripts and CI tasks that should authenticate as themselves, not as a user
  3. Refresh tokens so interactive clients don’t have to re-prompt every hour
  4. Token introspection and revocation so an admin can kill a leaked credential
  5. OpenID Connect — an id_token, a /userinfo endpoint, a JWKS endpoint, and a discovery document at /.well-known/openid-configuration
  6. Scope-based API protection — different bearer tokens for feeds:read vs feeds:write
  7. Re-use my existing users table. Non-negotiable. I didn’t want a second identity store.

That last point ruled out a few options. Keycloak and Hydra are technically perfect but operationally enormous: a separate service, a separate database, a sync layer back to my Phoenix users. Auth0 / Clerk / WorkOS would do the job but they own my users, my redirect URIs, and my pricing curve. Rolling my own was the worst option of all.

boruta_auth slots into the same OTP application, uses the same Repo, and lets me bridge my User schema with a small adapter module. That was the deciding factor.

What boruta gives you out of the box

It’s worth being explicit about this, because the value proposition is “you write less spec-compliance code, not less integration code.”

Boruta implements the RFCs for:

  • OAuth 2.0 Authorization Framework (RFC 6749)
  • Bearer Token Usage (RFC 6750)
  • PKCE (RFC 7636)
  • Token Introspection (RFC 7662)
  • Token Revocation (RFC 7009)
  • JSON Web Tokens (RFC 7519)

And the OpenID Connect bits:

  • Core (id_token, /userinfo, claims)
  • Discovery (/.well-known/openid-configuration)
  • JWKS

It ships generators that create the database migrations for oauth_clients, oauth_tokens, and oauth_scopes, and a Boruta.Ecto.Admin API for managing them. Application code never writes to those tables directly — the public surface is a small set of contexts that fan out into the spec internals.

What boruta does not do is decide what your users are. That’s the bridge you have to build yourself.

The bridge: SlashFeed.OAuth.ResourceOwners

Boruta’s Boruta.Oauth.ResourceOwners behaviour is the single integration point between the library and your user store. You implement four callbacks:

defmodule SlashFeed.OAuth.ResourceOwners do
  @moduledoc """
  Bridges boruta's OAuth resource-owner abstraction to the SlashFeed `User`
  schema. Implements the `Boruta.Oauth.ResourceOwners` behaviour.
  """

  @behaviour Boruta.Oauth.ResourceOwners

  alias SlashFeed.Accounts.User
  alias SlashFeed.Repo
  alias Boruta.Oauth.ResourceOwner

  @impl true
  def get_by(username: email) when is_binary(email) do
    case Repo.get_by(User, email: email) do
      %User{} = user -> {:ok, build_resource_owner(user)}
      nil -> {:error, "User not found"}
    end
  end

  def get_by(sub: sub) when is_binary(sub) do
    case Repo.get_by(User, id: sub) do
      %User{} = user -> {:ok, build_resource_owner(user)}
      nil -> {:error, "User not found"}
    end
  end

  @impl true
  def check_password(%ResourceOwner{sub: sub}, password) when is_binary(password) do
    case Repo.get_by(User, id: sub) do
      %User{} = user ->
        if User.valid_password?(user, password),
          do: :ok,
          else: {:error, "Invalid credentials"}

      nil ->
        {:error, "User not found"}
    end
  end

  @impl true
  def authorized_scopes(%ResourceOwner{}), do: []

  @impl true
  def claims(%ResourceOwner{sub: sub, username: email}, scope) do
    base = %{"sub" => sub}

    if has_profile_scope?(scope) do
      user = Repo.get_by(User, id: sub)

      Map.merge(base, %{
        "email" => email,
        "email_verified" => not is_nil(user && user.confirmed_at)
      })
    else
      base
    end
  end
end

That’s it. About a hundred lines of code, mostly pattern matching, and now boruta_auth knows how to look up my users, verify their passwords, and produce OpenID Connect claims for them.

A few things worth pointing out:

  • sub is to_string(user.id). UUID v7 strings travel cleanly through tokens.
  • authorized_scopes/1 returns []. I don’t want any user to have implicit grants — scopes are always requested by the client and explicitly approved.
  • claims/2 is scope-aware. A bare openid token only gets sub. Adding profile or email unlocks the email claim. This is the OIDC spec’s “minimum disclosure” principle in code form.

Wiring up the routes

Boruta does not generate controllers; it gives you the building blocks and you wire them into your router. I split them into two scope "/oauth" blocks because some endpoints need the browser session (login + consent) and some don’t (token exchange, introspection, revocation):

# ── OAuth 2.0 API endpoints (no session, JSON only) ────────────────
scope "/oauth", SlashFeedWeb.Oauth do
  pipe_through :api

  post "/token", TokenController, :token
  post "/revoke", RevokeController, :revoke
  post "/introspect", IntrospectController, :introspect
end

# ── OAuth 2.0 browser endpoints (need session for user login/consent) ───
scope "/oauth", SlashFeedWeb.Oauth do
  pipe_through :browser

  get "/authorize", AuthorizeController, :authorize
  post "/authorize", AuthorizeController, :authorize
end

And separately the OpenID Connect endpoints, which are pure JSON:

scope "/.well-known", SlashFeedWeb.Openid do
  pipe_through :api
  get "/openid-configuration", DiscoveryController, :index
  get "/jwks.json", JwksController, :jwks_index
end

scope "/openid", SlashFeedWeb.Openid do
  pipe_through :api
  get "/userinfo", UserinfoController, :userinfo
  post "/userinfo", UserinfoController, :userinfo
end

Each controller is thin. The AuthorizeController is the most interesting one because it has to interleave with my existing user session: when an unauthenticated user hits /oauth/authorize, I bounce them through the regular login page, then return them to the consent screen on success. Everything else delegates almost directly to Boruta.Openid.* and Boruta.Oauth.* modules.

Protecting API endpoints

The whole point of issuing tokens is that something checks them. That something is SlashFeedWeb.Plugs.OAuthBearer:

defmodule SlashFeedWeb.Plugs.OAuthBearer do
  import Plug.Conn
  alias SlashFeed.Accounts
  alias Boruta.Oauth.Authorization

  def init(opts), do: opts

  def call(conn, _opts) do
    with {:ok, raw} <- extract_token(conn),
         {:ok, token} <- Authorization.AccessToken.authorize(value: raw) do
      conn
      |> assign(:oauth_token, token)
      |> assign(:current_user, load_user(token.sub))
    else
      _ -> reject(conn, "invalid_token", "Bearer token is invalid or expired")
    end
  end

  defp extract_token(conn) do
    case get_req_header(conn, "authorization") do
      ["Bearer " <> token | _] -> {:ok, String.trim(token)}
      ["bearer " <> token | _] -> {:ok, String.trim(token)}
      _ -> :error
    end
  end
end

Two assigns come out of it: oauth_token (the full Boruta token struct, including sub and scope) and current_user (the actual User, or nil for client-credentials tokens that don’t represent a person).

Scope enforcement is a separate plug, SlashFeedWeb.Plugs.RequireScope. It checks the token’s scope string against a required list and halts with 403 if any is missing. The split is deliberate: OAuthBearer decides who you are, RequireScope decides what you can do. Composing them in the router is one-liners:

pipeline :api_authenticated do
  plug SlashFeedWeb.Plugs.OAuthBearer
end

# in a scope
plug SlashFeedWeb.Plugs.RequireScope, ["feeds:write"]

Issuer binding and HTTPS — the runtime quirk

One detail bit me during development: OAuth tokens are bound to an issuer (the iss claim in the ID token, and the value advertised at the discovery endpoint). Clients reject tokens whose iss doesn’t match the URL they fetched the discovery document from.

In production the issuer is https://slashfeed.app. In dev it’s http://localhost:3500. In test it’s http://localhost:3502. Hard-coding any of those is a recipe for “works on my machine.”

I already had this problem for WebAuthn (which binds credentials to an origin), so I reused the same resolver:

if config_env() != :test do
  boruta_issuer =
    System.get_env("BORUTA_ISSUER") ||
      SlashFeed.WebAuthn.Origin.resolve(System.get_env())

  config :boruta, Boruta.Oauth,
    repo: SlashFeed.Repo,
    issuer: boruta_issuer,
    contexts: [resource_owners: SlashFeed.OAuth.ResourceOwners]
end

SlashFeed.WebAuthn.Origin.resolve/1 walks the env vars (WEBAUTHN_ORIGIN, APP_URL, PHX_HOST, falling back to localhost) and returns the canonical URL. One source of truth for “where is this app actually running.” Tokens and passkeys both depend on it, and both break in the same way if it’s wrong.

Testing the integration

This is the part I want to dwell on, because the test surface for an OAuth server is non-obvious. There are three layers worth testing:

1. The ResourceOwners bridge (unit)

The bridge is pure-Elixir, talks only to my own Repo, and has zero spec content. Standard DataCase test:

defmodule SlashFeed.OAuthResourceOwnersTest do
  use SlashFeed.DataCase, async: true

  import SlashFeed.AccountsFixtures

  alias SlashFeed.OAuth.ResourceOwners
  alias Boruta.Oauth.ResourceOwner

  describe "get_by/1" do
    test "returns a ResourceOwner by username (email)" do
      user = user_fixture()
      assert {:ok, %ResourceOwner{} = ro} = ResourceOwners.get_by(username: user.email)
      assert ro.sub == to_string(user.id)
      assert ro.username == user.email
    end

    test "returns a ResourceOwner by sub" do
      user = user_fixture()
      assert {:ok, %ResourceOwner{}} = ResourceOwners.get_by(sub: to_string(user.id))
    end

    test "returns an error tuple when the user is missing" do
      assert {:error, _} = ResourceOwners.get_by(username: "[email protected]")
    end
  end

  describe "check_password/2" do
    test "ok for the right password" do
      user = user_fixture() |> set_password()
      ro = %ResourceOwner{sub: to_string(user.id)}
      assert :ok = ResourceOwners.check_password(ro, valid_user_password())
    end

    test "rejects bad password" do
      user = user_fixture() |> set_password()
      ro = %ResourceOwner{sub: to_string(user.id)}
      assert {:error, _} = ResourceOwners.check_password(ro, "wrong-password!")
    end
  end

  describe "claims/2" do
    test "returns sub-only claims when scope is empty" do
      user = user_fixture()
      ro = %ResourceOwner{sub: to_string(user.id), username: user.email}
      assert ResourceOwners.claims(ro, "") == %{"sub" => to_string(user.id)}
    end

    test "includes email when scope contains profile" do
      user = user_fixture()
      ro = %ResourceOwner{sub: to_string(user.id), username: user.email}
      claims = ResourceOwners.claims(ro, "openid profile")
      assert claims["email"] == user.email
      assert is_boolean(claims["email_verified"])
    end
  end
end

This catches the entire class of regressions where I change my User schema and forget that the OAuth flow depends on a specific field. The minimum-disclosure logic for claims is also verified directly: empty scope → only sub; openid profile → adds email and email_verified.

2. The bearer plug (controller-level)

OAuthBearer is what every protected API endpoint goes through, so it gets its own test module. The test cases are deliberately negative — I’m verifying the plug refuses the wrong things:

defmodule SlashFeedWeb.Plugs.OAuthBearerTest do
  use SlashFeedWeb.ConnCase, async: true
  alias SlashFeedWeb.Plugs.OAuthBearer

  describe "call/2" do
    test "rejects when there is no Authorization header", %{conn: conn} do
      conn = OAuthBearer.call(conn, [])
      assert conn.halted
      assert conn.status == 401
    end

    test "rejects when the Authorization header is not a Bearer token", %{conn: conn} do
      conn =
        conn
        |> put_req_header("authorization", "Basic abc")
        |> OAuthBearer.call([])

      assert conn.halted
      assert conn.status == 401
    end

    test "rejects an invalid Bearer token", %{conn: conn} do
      conn =
        conn
        |> put_req_header("authorization", "Bearer not-a-real-token")
        |> OAuthBearer.call([])

      assert conn.halted
      assert conn.status == 401
      [www_auth | _] = get_resp_header(conn, "www-authenticate")
      assert www_auth =~ "Bearer"
    end
  end
end

And then RequireScope gets matching tests that simulate an already-validated token assigned to the conn:

test "passes when the token has all required scopes", %{conn: conn} do
  conn =
    conn
    |> assign(:oauth_token, %{scope: "feeds:read entries:read"})
    |> RequireScope.call(["feeds:read"])

  refute conn.halted
end

test "halts with 403 when missing a scope", %{conn: conn} do
  conn =
    conn
    |> assign(:oauth_token, %{scope: "feeds:read"})
    |> RequireScope.call(["feeds:write"])

  assert conn.halted
  assert conn.status == 403
end

The clean separation — bearer plug authenticates, scope plug authorises — makes each one independently testable. No mocks, no shared state, no LiveView hooks.

3. The end-to-end curl walkthrough (manual)

LiveView tests cover the in-process flows. The actual interop with a real client — does curl work, does the discovery doc parse, does PKCE round-trip correctly — I check by hand whenever I touch the OAuth layer.

There’s a full curl-based walkthrough in docs/oauth2-boruta.md § End-to-End Testing Guide that covers:

  1. Seeding the scope catalog with mix slashfeed.oauth.seed_scopes
  2. Registering a confidential and a public client with mix slashfeed.oauth.create_client
  3. Hitting /.well-known/openid-configuration and verifying every endpoint URL
  4. Doing the Client Credentials grant with curl and decoding the resulting access token
  5. Doing the Authorization Code + PKCE flow (generate the verifier, hit /oauth/authorize in a browser, exchange the code at /oauth/token)
  6. Calling /openid/userinfo with the access token
  7. Refreshing the access token with the refresh token

Each step has the exact curl invocation and the expected JSON shape. I run through it before any release that touches slashfeed/lib/slashfeed/oauth/ or slashfeed/lib/slashfeed_web/controllers/oauth/.

Mix tasks instead of a UI for client registration

The admin UI for managing OAuth clients is real (/admin/oauth/clients, /admin/oauth/tokens, /admin/oauth/scopes), but I wrote CLI tasks first because they’re easier to script and test:

# Seed the standard scope catalog (idempotent)
mix slashfeed.oauth.seed_scopes

# Register an Authorization Code + PKCE client
mix slashfeed.oauth.create_client \
  --name "CLI" \
  --redirect-uri "http://localhost:8000/callback" \
  --grants "authorization_code refresh_token" \
  --scopes "openid profile feeds:read" \
  --pkce

# List + delete
mix slashfeed.oauth.list_clients
mix slashfeed.oauth.delete_client --id <uuid>

The confidential client secret is printed exactly once, at creation time. There is no admin endpoint to retrieve it later. That’s not an oversight — it’s the only way to make sure the secret isn’t sitting in a database row that can leak. If you lose it, you rotate the client. That’s how OAuth is supposed to work.

What I’d do differently

Read the docs before writing the resource-owner adapter. I spent half a day chasing a phantom bug where check_password/2 was getting nil for sub. It turned out that boruta calls check_password with the ResourceOwner struct from the in-flight request, not one freshly fetched from the database — so I needed to handle the case where the struct came in with only sub populated. Reading the behaviour docs more carefully would’ve saved a few hours.

Don’t try to validate redirect URIs creatively. OAuth has a specific concept of “exact-match” for redirect URIs, and boruta enforces it. Trying to be clever — “well, it’s the same host and the same port, just a different path” — is exactly the kind of thing that leads to authorization-code-interception attacks. The library is right and my instinct was wrong.

Keep the ID-token claim minimal. It’s tempting to stuff the user’s subscription count, their reading streak, and their last login IP into the token. Don’t. Tokens are forwarded to wherever the client logs them. The /userinfo endpoint exists for everything beyond the bare sub.


The whole integration is about 600 lines of application code on top of boruta_auth‘s ~10k lines of spec implementation. That ratio is roughly what you should expect when adding OAuth to an existing app: the library handles the protocol, you handle the bridge to your own data model. The temptation to write a “quick OAuth-ish thing” is real and you should resist it. Use a library that has read the RFCs, write the smallest possible adapter to your user store, and spend the saved time on the integration tests.

SlashFeed is a personal RSS reader built in Elixir/Phoenix. I’m documenting the development process here as I go.