Fireauth
Firebase Auth helpers for Elixir apps:
- Verify Firebase ID tokens (RS256) and session cookies using Google's public keys.
- Mint Firebase session cookies (server-side, requires admin service account).
- Start and finish server-owned OAuth sign-in flows through Identity Platform.
- Send Firebase email-link sign-in emails from the server.
- Plug middleware for token verification, session cookies, and hosted auth files.
Install
Add to your mix.exs:
{:fireauth, "~> 0.7.0"},
You can also feed the LLM_SETUP.md file into your agent to automate setup.
Configuration
Project ID (required)
config :fireauth, firebase_project_id: "your-project-id"
Or via env var: FIREBASE_PROJECT_ID.
API Key (required for server-owned OAuth and email-link flows)
config :fireauth, firebase_web_config: %{"apiKey" => "AIza..."}
Or via env var: FIREBASE_API_KEY.
Admin Service Account (required for session cookies)
config :fireauth, firebase_admin_service_account: %{
"client_email" => "firebase-adminsdk-...@your-project.iam.gserviceaccount.com",
"private_key" => "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"project_id" => "your-project-id"
}
Or via env var (JSON or base64-encoded JSON): FIREBASE_ADMIN_SERVICE_ACCOUNT.
Auth Flows
1) Popup Flow
The simplest integration. The client signs in with the Firebase JS SDK popup, obtains an ID token, and sends it to your backend. No hosted auth files needed.
Option A: Bearer header — client sends Authorization: Bearer <idToken> on
each request. Stateless, no admin credentials required.
defmodule MyApp.Router do
use Plug.Router
plug :match
plug Fireauth.Plug, on_invalid_token: :unauthorized
plug :dispatch
get "/protected" do
case conn.assigns[:fireauth] do
%{user: user} -> send_resp(conn, 200, "Hello #{user.email}")
_ -> send_resp(conn, 401, "unauthorized")
end
end
endOption B: Session cookie — client POSTs the ID token once, backend mints an
httpOnly cookie. Better for Phoenix/LiveView. Requires admin credentials.
# Mount session endpoints (outside your :browser pipeline to avoid Phoenix CSRF conflicts)
forward "/auth/firebase",
to: Fireauth.Plug.SessionRouter,
init_opts: [cookie_secure: false] # true in production
# Verify session cookie on every request
plug Fireauth.Plug.SessionCookie, on_invalid_cookie: :unauthorizedThe client exchanges its ID token for a session cookie:
// After signInWithPopup succeeds:
const idToken = await user.getIdToken();
const csrf = await fetch("/auth/firebase/csrf").then(r => r.json());
await fetch("/auth/firebase/session", {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": csrf.csrfToken },
body: JSON.stringify({ idToken, csrfToken: csrf.csrfToken })
});2) Redirect Flow
Uses Firebase's signInWithRedirect. Requires serving Firebase's hosted auth
files from your domain (modern browsers block third-party cookies). Fireauth
provides Fireauth.Snippets.client/1 to wire the client-side start/verify flow
without a bundler.
Endpoint setup — serve hosted auth files before your router:
HostedControllermeans we're serving static html from Fireauth itselfProxyControllermeans we're proxying the requests to upstream Google
# In your Endpoint (before the router)
plug Fireauth.Plug,
callback_overrides: %{
"/__/auth/handler" => Fireauth.HostedController,
"/__/auth/iframe" => Fireauth.HostedController,
"/__/auth/handler.js" => Fireauth.HostedController,
"/__/auth/iframe.js" => Fireauth.HostedController,
"/__/auth/experiments.js" => Fireauth.HostedController,
"/__/firebase/init.json" => Fireauth.ProxyController
}Session endpoints + cookie verification (same as popup Option B):
forward "/auth/firebase",
to: Fireauth.Plug.SessionRouter,
init_opts: [cookie_secure: false]
plug Fireauth.Plug.SessionCookieStart page — embed the snippet and trigger redirect:
# In your template (HEEx)
{Fireauth.Snippets.client(return_to: @return_to, session_base: "/auth/firebase", debug: true)}
<script>
fireauth.start(
{ provider: "google.com", ready: () => !!window.myFirebaseAuth },
function (providerId) {
const auth = firebase.auth.getAuth();
return firebase.auth.signInWithRedirect(auth, new firebase.auth.GoogleAuthProvider());
}
)
.error(s => console.warn("start error", s.code, s.message))
.onStateChange(s => console.debug("state", s.stage));
</script>Verify page — resolves the returning user and exchanges the token:
{Fireauth.Snippets.client(return_to: @return_to, session_base: "/auth/firebase")}
<script>
fireauth.verify(
{ requireVerified: true, getAuth: () => firebase.auth.getAuth() },
function (s) {
if (s.type === "error") showError(s.message);
if (s.loading) showLoading(s.message);
}
)
.success(() => showLoading("Login successful. Redirecting..."))
.error(s => showError(s.message));
</script>3) Server-Owned OAuth Flow
The server drives the OAuth redirect directly via Identity Platform APIs. No Firebase JS SDK is needed for the auth flow itself — useful when you want full control over the login UX without Firebase's redirect/popup screens.
# 1. Start: build provider redirect URL
{:ok, start_result} =
Fireauth.start_oauth_sign_in("google.com", "https://example.com/auth/callback/google",
otp_app: :my_app
)
# Redirect browser to the provider
redirect(conn, external: start_result.auth_uri)
# Save start_result.session_id (e.g. in the session) for the callback# 2. Callback: exchange the provider response for a Firebase ID token
{:ok, sign_in_result} =
Fireauth.finish_oauth_sign_in(
# The full callback URL including query params
"https://example.com/auth/callback/google?code=abc",
session_id, # from step 1
nil,
otp_app: :my_app
)
# 3. Optionally mint a session cookie
{:ok, session_cookie} =
Fireauth.create_session_cookie(sign_in_result.firebase_id_token,
otp_app: :my_app,
valid_duration_s: 60 * 60 * 24 * 14
)finish_oauth_sign_in/4 returns a %Fireauth.ServerAuth.SignInResult{} with
firebase_id_token, email, display_name, is_new_user, and more.
Helpers
Token & Session
# Verify a Firebase ID token (RS256)
{:ok, claims} = Fireauth.verify_id_token(id_token)
# Verify a Firebase session cookie
{:ok, claims} = Fireauth.verify_session_cookie(cookie)
# Exchange an ID token for a session cookie (requires admin service account)
{:ok, cookie} = Fireauth.create_session_cookie(id_token,
valid_duration_s: 60 * 60 * 24 * 14 # max 14 days, default 5 days
)User & Identity
# Convert claims to a user struct
user = Fireauth.claims_to_user_attrs(claims)
# => %Fireauth.User{firebase_uid: "...", email: "...", name: "...", ...}
# Check provider identities (works with claims, user, or %Fireauth{} struct)
Fireauth.has_identity?(user, "google.com") # => true/false
Fireauth.identity(user, "google.com") # => "google-uid" or nil
Fireauth.identities(user) # => %{"google.com" => ["..."], ...}Server-Owned Auth
# Start a server-owned OAuth sign-in
{:ok, %Fireauth.ServerAuth.StartResult{auth_uri: url, session_id: sid}} =
Fireauth.start_oauth_sign_in("google.com", callback_url, otp_app: :my_app)
# Finish the OAuth callback
{:ok, %Fireauth.ServerAuth.SignInResult{firebase_id_token: token}} =
Fireauth.finish_oauth_sign_in(request_uri, session_id, post_body, otp_app: :my_app)
# Send an email-link sign-in email
{:ok, %Fireauth.EmailLinkSender.Result{email: email}} =
Fireauth.send_email_sign_in_link("user@example.com", continue_url, otp_app: :my_app)send_email_sign_in_link/3 sends the Firebase email-link sign-in email via
Identity Toolkit (accounts:sendOobCode). Completing the sign-in still uses the
Firebase Web SDK on your verify page via signInWithEmailLink(...).
Custom Tokens & Account Management
# Mint a custom token for server-side auth flows (e.g. signInWithCustomToken)
{:ok, custom_token} = Fireauth.create_custom_token("user-123", claims: %{"role" => "admin"})
# Exchange a custom token for a Firebase ID token (server-side)
{:ok, id_token} = Fireauth.exchange_custom_token(custom_token, otp_app: :my_app)
# Unlink a provider from a user
{:ok, _response} = Fireauth.unlink_provider(id_token, "google.com", otp_app: :my_app)Plugs
| Plug | Purpose |
|---|---|
Fireauth.Plug |
Verifies Authorization: Bearer <idToken> and serves hosted auth files via callback_overrides |
Fireauth.Plug.SessionRouter |
Mounts GET /csrf, POST /session, POST /logout endpoints for session cookie flow |
Fireauth.Plug.SessionCookie |
Verifies the httpOnly session cookie and attaches conn.assigns.fireauth |
Fireauth.Plug.FirebaseAuthProxy | Transparent reverse proxy for Firebase hosted auth files |
Fireauth.Plug options:
:on_invalid_token—:ignore(default),:unauthorized, or{:assign_error, key}:callback_overrides— map of path to controller for hosted auth routing:default_controller— fallback for unmatched hosted paths (default:Fireauth.HostedController, setnilto disable)
Fireauth.Plug.SessionRouter options:
:valid_duration_s— cookie lifetime in seconds (300–1,209,600, default: 432,000 = 5 days):cookie_secure—truein production,falsefor local dev:cookie_same_site— default"Lax":session_cookie_name— default"session":csrf_cookie_name— default"fireauth_csrf"
Fireauth.Plug.SessionCookie options:
:on_invalid_cookie—:ignore(default),:unauthorized, or{:assign_error, key}:cookie_name— default"session"
Snippets
Fireauth.Snippets provides HEEx-embeddable helpers (depends on phoenix_html, not phoenix):
| Function | Purpose |
|---|---|
client(opts) |
Embeds the window.fireauth client API (start + verify) |
hosted_auth_handler_bootstrap/0 |
Firebase bootstrap <script> tags for /__/auth/handler |
hosted_auth_handler_document/0 |
Full HTML document for /__/auth/handler |
hosted_auth_iframe_bootstrap/0 |
Firebase bootstrap <script> tags for /__/auth/iframe |
hosted_auth_iframe_document/0 |
Full HTML document for /__/auth/iframe |
client/1 options:
:return_to— where to redirect after session is established (default:"/"):session_base— mount path forSessionRouter(default:"/auth/firebase"):require_verified— require verified email (default:true):debug— enable[fireauth]console logging (default:false)
window.fireauth API:
start(opts, callback)— call your callback to trigger Firebase redirect. Supportsopts.ready(polled until truthy) andopts.readyTimeout(default 5000ms).verify(opts, callback)— resolve current user viaopts.getAuth(), exchange ID token for session cookie, redirect toreturn_to. Returns chainable.success(cb).error(cb).onStateChange(cb).onStateChange(cb)/onError(cb)/onSuccess(cb)— global listeners.
Hosted Auth Routing
To support redirect-mode auth, serve Firebase's helper files from your domain
using callback_overrides. Two controller options:
Fireauth.HostedController— serves local snippet-based HTML forhandlerandiframe, proxiesactionandaction.jsto Firebase upstream.Fireauth.ProxyController— transparently proxies everything tohttps://<project>.firebaseapp.comwith in-memory caching.
License
MIT
Appendix: Custom Auth Handler
Firebase's default /__/auth/handler page shows its own loading indicators and
UI during the redirect flow. If you want to replace that with your own branded
page, override the path with a {Module, :action} tuple in callback_overrides:
plug Fireauth.Plug,
callback_overrides: %{
"/__/auth/handler" => {MyAppWeb.FirebaseHostedAuthController, :handler},
"/__/auth/handler.js" => Fireauth.ProxyController,
"/__/firebase/init.json" => Fireauth.HostedController
}
Your controller must include Fireauth.Snippets.hosted_auth_handler_bootstrap/0
to preserve Firebase's auth relay behavior. You can then hide Firebase's injected
containers with CSS and render your own UI:
defmodule MyAppWeb.FirebaseHostedAuthController do
use MyAppWeb, :controller
def handler(conn, _params) do
body = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
#{Fireauth.Snippets.hosted_auth_handler_bootstrap()}
<style>
/* Hide Firebase's injected UI */
#pending-screen, #continue-screen, #error-screen,
.firebase-container { display: none !important; }
</style>
</head>
<body>
<main>Completing authentication...</main>
</body>
</html>
"""
conn
|> put_resp_content_type("text/html")
|> send_resp(200, body)
|> halt()
end
endIf you just want the default Firebase handler served from your own controller without customization:
def handler(conn, _params) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, Fireauth.Snippets.hosted_auth_handler_document())
|> halt()
end