EzAuth supports multiple strategies, and keeps a small public API. The library draws inspiration from Clerk's developer experience, Better Auth and Supabase's ergonomics, and Phoenix's auth convention/semantics.

Database

EzAuth manages the following tables under the auth schema/prefix:

Users

The auth principal. Holds the user's profile (name, username, metadata) alongside the password credential. Profile fields are not collected by the standard sign-up flow; see Profile below.

columntypenotes
idbigint PKauto-generated surrogate key
namestring, nullabledisplay name
usernamecitext, nullable, uniqueprofile alias; not a sign-in identifier on its own
hashed_passwordstring, nullableBcrypt hash; nullable for passwordless users
metadatajsonb, NOT NULL, default {}host-defined profile data; see Profile
anonymousboolean, NOT NULL, default falsemarks anonymous/guest accounts
inserted_atutc_datetimerecord creation timestamp
updated_atutc_datetimerecord update timestamp

Identities

Ways a user is known. Multi-valued: a user may have several emails or phones.

columntypenotes
idbigint PKauto-generated surrogate key
user_idbigint FK -> users, NOT NULL, cascadeowning user
typeEcto.Enum, NOT NULLidentity kind (:email, :phone, plus social provider atoms)
valuecitext, NOT NULLthe email or phone string
verified_atutc_datetime, nullableset when ownership proof completes
inserted_atutc_datetimerecord creation timestamp
updated_atutc_datetimerecord update timestamp

Sessions

Authenticated sessions. Opaque tokens stored server-side and referenced by cookie.

columntypenotes
idbigint PKauto-generated surrogate key
user_idbigint FK -> users, NOT NULL, cascadeowning user
tokenbinary, NOT NULL, uniqueopaque random bytes; lives in the cookie
expires_atutc_datetime, NOT NULLhard expiry after which the session is invalid
inserted_atutc_datetimerecord creation timestamp
updated_atutc_datetimerecord update timestamp

Verifications

Single-use challenges tied to an auth flow. Immutable after insert.

columntypenotes
idbigint PKauto-generated surrogate key
user_idbigint FK -> users, NOT NULL, cascadeowning user
typeEcto.Enum, NOT NULLverification kind: :email, :phone, :recovery, :email_change, :phone_change
tokenstring, NOT NULL, uniqueSHA-256 hash of the raw token
valuestring, nullableflow-specific payload (e.g., the new email for :email_change)
expires_atutc_datetime, NOT NULLhard expiry after which the token is invalid
inserted_atutc_datetime, NOT NULLrecord creation timestamp; no updated_at since rows are immutable

Structure

  • lib/ez_auth.ex: top-level integration surface (route macros, LiveView on_mount, use macro)
  • lib/ez_auth/: core modules (accounts, auth, config, dispatcher, handler, sender, strategy, error_helpers)
  • lib/ez_auth/accounts/: Ecto schemas and persistence helpers (user, identity, session, verification)
  • lib/ez_auth/strategies/: authentication strategy implementations and placeholders (password, magic link, Apple, GitHub, Google, Microsoft)
  • lib/ez_auth/scopes/: context structs passed through the auth pipeline (user scope, sender scope)
  • lib/ez_auth/ui/: unstyled LiveComponents and function components for sign-in, sign-up, and verify flows

Boundaries

  • EzAuth: the entry point host apps interact with. Provides route macros (auth_routes/1), LiveView on_mount/4 hooks, and a __using__ macro that imports auth helpers into router modules. Nothing in this module touches persistence directly.

  • EzAuth.Accounts: the stable public boundary for all auth domain operations. User creation and lookup, identity linking/unlinking, session token generation and revocation, verification issuance and consumption. Verification functions also dispatch delivery through the configured EzAuth.Sender behaviour. All session and verification details are exposed as semantic operations, never as raw persistence calls.

  • EzAuth.Auth: the web/connection layer. Manages session cookies, assigns the current authenticated scope to conn/socket, and provides plug-level guards (require_authenticated, redirect_if_authenticated). sign_in_user/2 is the standard entrypoint for creating sessions from HTTP and LiveView flows. This module calls into Accounts for token storage but never bypasses it.

  • Strategies: self-contained flow orchestrators. Each strategy uses EzAuth.Strategy with a stable :name and :actions, handles request parsing, and decides which auth flow is being executed. Routes are derived from those values, so name: :magic_link, actions: [:request, :verify] produces /auth/magic-link/request and /auth/magic-link/verify. Strategies call Accounts for user lookups and verification, and Auth for session creation. They do not own sign-up, session persistence, or verification state. The dispatcher routes strategy outcomes to host-app callbacks defined by the EzAuth.Handler behaviour.

  • EzAuth.Accounts.* (User, Identity, Session, Verification): internal Ecto schemas and persistence helpers. These modules support Accounts but should never be called directly by host apps or strategies. The public boundary is always Accounts.

Auth Flow

Sign-up

Sign-up is email + password only. Passwordless flows (magic_link, sms_otp) do not have a separate sign-up surface; they are sign-in flows that create the user on first verified delivery (see "Passwordless creates on demand" below).

  1. Validate email + password
  2. Create user, attach unverified email identity, dispatch email verification
Dispatcher.sign_up/2
 Accounts.create_user_with_password/1
 Handler.handle_success/3

The user, their email identity, and a verification token are created together. The raw token is never returned to the caller; it dispatches out-of-band through the configured EzAuth.Sender.

Passwordless creates on demand

MagicLink.handle(:request, _, _) and SmsOtp.handle(:request, _, _) look up the requested identity through Accounts.find_or_create_email_identity/1 or find_or_create_phone_identity/1. If a verified identity exists, they reuse it. If not, the helper inserts a fresh passwordless user plus an unverified identity, and the strategy issues the verification. There is no separate flag: enabling a passwordless strategy is the opt-in. Hosts that want sign-in only must keep those strategies disabled (or wrap the request route with their own access check).

Sign-in

Password

  1. Look up user by identity and verify password
  2. Create session (or reject on mismatch)
Dispatcher.dispatch/2
 EzAuth.Strategies.Password.handle/3
 Handler.handle_success/3

The password strategy looks up the user by their verified email identity, then verifies the password hash. On success it delegates to Auth.sign_in_user/2, which generates the token, sets the session cookie, and assigns a live socket ID for LiveView disconnect broadcasts. Password is anchored to email; phone + password and username + password are not supported.

Magic link

  1. Look up user by email and send magic link
  2. Consume token, verify email, and create session
Request
Dispatcher.dispatch/2
 EzAuth.Strategies.MagicLink.handle/3
 Handler.handle_success/3

The request action does not create a session. It issues a verification token and dispatches it through the sender. The user is not signed in until they click the link and hit the verify action.

Verify
Dispatcher.dispatch/2
 EzAuth.Strategies.MagicLink.handle/3
 Handler.handle_success/3

Consuming the token both authenticates the user and marks their email identity as verified if it wasn't already. This means clicking a magic link doubles as email confirmation, so a separate verification step is not needed after magic link sign-in.

Sign-out

  1. Revoke session token and disconnect live sessions
  2. Clear cookie and redirect
Dispatcher.sign_out/2
 Auth.sign_out_user/2
 Handler.handle_success/3

Sign-out revokes the current session token and broadcasts a disconnect to any LiveView socket tied to it, so real-time UIs reflect the sign-out immediately instead of waiting for the next request cycle.

Session

Auth.fetch_current_scope/2 is called on every request (via plug pipeline or LiveView on_mount) to load the authenticated user from the session cookie. If the token is invalid or expired, the scope is set to nil.

Accounts.revoke_user_sessions/1 invalidates all sessions for a user at once. Host apps should call it after password changes or other sensitive credential updates, typically paired with Auth.disconnect_sessions/1 to broadcast disconnects to live sockets for the revoked tokens.

Design Notes

Security

  • Passwords are hashed with Bcrypt and stored in users.hashed_password. The schema's plaintext :password field is virtual and marked redact: true, so it never appears in logs or debug output and never reaches the database — it is hashed and dropped from the changeset on insert.

  • Verification tokens are hashed with SHA-256 before being stored in verifications.token. The raw token only exists in memory at creation time, so it can be sent out-of-band (via email or SMS). The public API never returns the raw token, and it's never persisted.

  • When you request a new verification link for the same user, type, and target (like the same email address), the old link is replaced and becomes invalid. Only the most recent link is valid; this avoids confusion when users request multiple links in a row.

  • When a verification token is used, the database row is deleted immediately. Redemption is idempotent on purpose, so retries, double-clicks, and link previews don't break the flow; the tradeoff is that two truly simultaneous redemptions can both succeed. We accept that narrow window because anyone holding the token already controls the inbox it was sent to. The type is also checked on use (email, SMS, password reset, etc.) so a token from one flow can never be consumed in another.

  • Session tokens are stored as raw bytes; verification tokens are stored as SHA-256 hashes. The difference tracks where each token travels. Session tokens stay inside a signed HTTP-only cookie, so the database row and the cookie share the same trust boundary; hashing the row would not add protection against anything the cookie doesn't already cover. Verification tokens travel out-of-band through email or SMS, so the stored row and the delivered token live on different trust boundaries, and hashing is what keeps a leaked row from becoming a usable token. EzAuth stays compatible with mix phx.gen.auth's split here on purpose.

  • The LiveView disconnect topic is "auth_sessions:" <> <Base64 session token>, kept compatible with mix phx.gen.auth's live_socket_id convention. Auth.disconnect_sessions/2 uses this shape to broadcast disconnects when sessions are revoked. Because the topic contains a live session token, anything that logs PubSub topics (LiveView debug logs, telemetry, external backends) will capture it. Scrub PubSub topic names from your log sinks; this is operational hygiene inherited from the Phoenix convention, not a library defect.

  • Strategies return specific error reasons like :not_found when it helps tests or handlers decide what to do. Preventing account enumeration is a UI concern: whatever layer turns a reason into a user-visible response must normalize enumerable outcomes into generic messages ("invalid credentials", "check your email"). Strategies hand typed reasons to EzAuth.Handler callbacks precisely so the UI layer can decide the messaging; exposing the raw reason to end users is what causes the leak, not the existence of the reason itself.

Identities

  • Only verified identities count as taken. Multiple people can sign up with the same email or phone while it's unverified; whoever verifies first claims the identity. Later verification attempts for the same value fail with {:error, :already_claimed}, and signing up against an already-verified value is rejected upfront. This avoids identity parking: an abandoned sign-up or malicious reservation cannot permanently block the real owner of an email or phone.

  • If sender delivery fails after sign-up, the user might be locked out because they never received the initial verification email and cannot request a new verification link through password recovery. Recovery only works for verified identities, so the system needs to handle delivery retries on its own.

  • EzAuth does not clean up unverified identities left behind by abandoned sign-ups, failed deliveries, or verification races. Your system should clean up stale unverified identities and their users so the real owner can sign up again and receive a fresh verification link. EzAuth does not pick the cleanup policy for the host because retention windows, sender reliability, and abuse controls vary by app.

  • Usernames live on users.username with a database-level unique constraint; there is no verification step for them.

  • Email verification and email magic-link sign-in use the same trust boundary: control of the inbox proves both ownership and authentication. Redeeming a valid email token may verify the identity and create a session in one step. If you need email confirmation without sign-in, enforce that separation in your app.

  • Once an identity is verified, the user can sign in with any enabled strategy that uses that identity type. For example, a verified email can be used for password login, magic link, or any other email-based strategy you've enabled. Verification proves the user controls the identity; strategies define the ways to authenticate with it. If you need stricter rules (e.g., "verification only, no magic link"), enforce them in your app.

  • Phone identities must be in E.164 format (e.g., +15551234567). EzAuth validates the format with the regex ^\+[1-9]\d{1,14}$; anything else fails validation. The library won't try to parse friendly formats like (555) 123-4567 because country and UX context varies. Normalize phone input in your app before passing it to EzAuth.

Profile

  • Profile fields (name, username, metadata) are not collected by the standard sign-up flow. EzAuth's UI and changesets only collect what's needed to authenticate: email + password for the password strategy, the appropriate identifier for passwordless. This avoids the uniqueness-reservation lifecycle a "complete your profile at sign-up" flow would introduce, especially for OAuth where providers vary in what they return.

  • Hosts collect profile data on their own terms via Accounts.update_user_profile/2, typically in a post-login step. The function casts :name, :username, and :metadata through User.profile_changeset/2 and reuses the same format/length rules configured under EzAuth.Config.name_format/0, EzAuth.Config.username_format/0, etc.

  • users.username keeps its unique index. The changeset performs an optimistic availability check via Accounts.username_taken?/1; the database-level unique constraint is the source of truth, and a conflict at insert time is what the host repairs.

Sessions

  • When a user changes their password or an admin locks them out, the host app must revoke all their sessions and disconnect any open pages. EzAuth provides two primitives: Accounts.revoke_user_sessions/1 deletes all session tokens from the database and returns them; Auth.disconnect_sessions/1 takes that token list and broadcasts disconnect messages to their LiveView sockets. Call both in sequence: tokens stop working immediately, and users see the sign-out without waiting for the next request. They're separate by design: the host controls when revocation happens based on its own business logic.

  • UI.TaskResetPassword always revokes every session for the user and broadcasts disconnect on a successful reset. No opt-out. Recovery flows usually run because the user suspects compromise, so a reset that leaves prior sessions live defeats the point. Hosts wiring their own settings-style password change should default to the same.

  • Auth.sign_out_user/2 has three branches on the revoke result. When there's no :user_token in the conn's session at all, it returns the conn unchanged (no renew_session/0, no redirect). This is intentional: there is no session to invalidate, so there's nothing for the library to do. Unrelated keys in the conn's session (application-specific state, flash, return-to) belong to the host and are not library state to clear. Hosts that want "always clear everything on the sign-out endpoint" should layer Plug.Conn.clear_session/1 before calling the library.

  • EzAuth has no user-delete function: host apps delete users through their own code. Cascade foreign keys handle the child rows (identities, sessions, verifications), so no orphaned auth data remains. Cascade is DB-only though: it won't disconnect open LiveView sockets. If you need to force an in-flight user offline, call revoke_user_sessions/1 + disconnect_sessions/1 (see above) before deleting.

Misc

  • EzAuth only enforces password length bounds (password_min_length and password_max_length). It does not check passwords against breach lists, dictionaries, or context-specific weak values. If you want that protection, validate passwords in your app before calling the sign-up flow.

  • The default password_min_length is 8, a deliberately low, configurable floor. The default password_max_length is 72 (bcrypt's practical limit). If your threat model needs a higher minimum (or a passphrase flow), override password_min_length in EzAuth.Config. This is a policy knob, not a security default the library is taking a position on; ASVS-L1's 12-character minimum and NIST SP 800-63B-4's 15-character recommendation are both reachable by setting the config value: picking them for every host would be a policy choice the library is explicitly avoiding.

  • Rate limiting and resend cooldowns are intentionally not built into EzAuth. Useful limits are application-specific: they depend on your app's identity strategy, which action is being protected, your storage backend, sender cost, and escalation rules. Implement them in your app. EzAuth may revisit this if a small, policy-neutral integration hook becomes feasible.