Adding a real OAuth 2.0 server to a Phoenix app with boruta
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:
- Authorization Code + PKCE for interactive clients (CLI, third-party apps, future mobile)
- Client Credentials for scripts and CI tasks that should authenticate as themselves, not as a user
- Refresh tokens so interactive clients don’t have to re-prompt every hour
- Token introspection and revocation so an admin can kill a leaked credential
-
OpenID Connect — an
id_token, a/userinfoendpoint, a JWKS endpoint, and a discovery document at/.well-known/openid-configuration -
Scope-based API protection — different bearer tokens for
feeds:readvsfeeds:write -
Re-use my existing
userstable. 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:
-
subisto_string(user.id). UUID v7 strings travel cleanly through tokens. -
authorized_scopes/1returns[]. I don’t want any user to have implicit grants — scopes are always requested by the client and explicitly approved. -
claims/2is scope-aware. A bareopenidtoken only getssub. Addingprofileoremailunlocks 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:
-
Seeding the scope catalog with
mix slashfeed.oauth.seed_scopes -
Registering a confidential and a public client with
mix slashfeed.oauth.create_client -
Hitting
/.well-known/openid-configurationand verifying every endpoint URL -
Doing the Client Credentials grant with
curland decoding the resulting access token -
Doing the Authorization Code + PKCE flow (generate the verifier, hit
/oauth/authorizein a browser, exchange the code at/oauth/token) -
Calling
/openid/userinfowith the access token - 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.