Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: phoenix-auth-customization description: MANDATORY when extending phx.gen.auth with custom fields. Invoke before adding usernames, profiles, or custom registration fields. file_patterns:
- "**/accounts.ex"
- "**/accounts/*.ex"
- "**/user.ex"
- "**/user_registration_live.ex"
auto_suggest: true
Phoenix Auth Customization
RULES — Follow these with no exceptions
- Never modify generated auth migrations — create separate migrations for custom fields; generated migrations are tested and correct
- Update `registration_changeset` to cast and validate new fields — don't create a separate changeset for initial registration
- Update test fixtures when adding required fields — missing fixture fields cause cryptic test failures across the entire test suite
- Confirm users in test fixtures for password-based auth — set
confirmed_at: DateTime.utc_now(:second)or tests requiring authenticated users will fail - Update both the registration form AND the `save/2` handler — the form must send the field, and the handler must pass it to the context
- Use `unique_constraint` + database unique index for uniqueness — never validate uniqueness in application code alone
Running phx.gen.auth
Start with the generator, then extend. Never hand-roll auth.
# Generate auth with LiveView (recommended)mix phx.gen.auth Accounts User users# This creates:# - Migration: priv/repo/migrations/*_create_users_auth_tables.exs# - Schema: lib/my_app/accounts/user.ex# - Context: lib/my_app/accounts.ex# - LiveViews: lib/my_app_web/live/user_*_live.ex# - Components: lib/my_app_web/controllers/user_session_controller.ex# - Plugs: lib/my_app_web/user_auth.ex# - Tests: test/my_app/accounts_test.exs, test/my_app_web/live/user_*_live_test.exs
Adding Custom Fields
Step 1: Create a Separate Migration
mix ecto.gen.migration add_username_to_users
defmodule MyApp.Repo.Migrations.AddUsernameToUsers douse Ecto.Migrationdef change doalter table(:users) doadd :username, :string, null: falseendcreate unique_index(:users, [:username])endend
Step 2: Update the Schema
defmodule MyApp.Accounts.User doschema "users" dofield :email, :stringfield :username, :string # Add new fieldfield :password, :string, virtual: true, redact: truefield :hashed_password, :string, redact: truefield :confirmed_at, :utc_datetimetimestamps()end# Update registration_changeset to include usernamedef registration_changeset(user, attrs, opts \\ []) douser|> cast(attrs, [:email, :username, :password])|> validate_required([:username])|> validate_username()|> validate_email(opts)|> validate_password(opts)enddefp validate_username(changeset) dochangeset|> validate_required([:username])|> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/,message: "only letters, numbers, and underscores")|> validate_length(:username, min: 3, max: 30)|> unsafe_validate_unique(:username, MyApp.Repo)|> unique_constraint(:username)endend
Step 3: Update the Registration LiveView
# In user_registration_live.ex — update the formdef render(assigns) do~H"""<.simple_form for={@form} id="registration_form" phx-submit="save" phx-change="validate"><.input field={@form[:email]} type="email" label="Email" required /><.input field={@form[:username]} type="text" label="Username" required /><.input field={@form[:password]} type="password" label="Password" required /><:actions><.button phx-disable-with="Creating account..." class="w-full">Create an account</.button></:actions></.simple_form>"""end# Update the save handler to pass usernamedef handle_event("save", %{"user" => user_params}, socket) docase Accounts.register_user(user_params) do{:ok, user} -># ... existing logic{:error, %Ecto.Changeset{} = changeset} ->{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}endend
Updating Test Fixtures
This is the most commonly missed step. Every test that creates a user will break if fixtures don't include new required fields.
defmodule MyApp.AccountsFixtures dodef unique_user_email, do: "user#{System.unique_integer()}@example.com"def unique_user_username, do: "user#{System.unique_integer([:positive])}"def valid_user_attributes(attrs \\ %{}) doEnum.into(attrs, %{email: unique_user_email(),username: unique_user_username(), # Add new required fieldpassword: "hello world!"})enddef user_fixture(attrs \\ %{}) do{:ok, user} =attrs|> valid_user_attributes()|> MyApp.Accounts.register_user()# Confirm user for password-based auth{:ok, user} =user|> Ecto.Changeset.change(%{confirmed_at: DateTime.utc_now(:second)})|> MyApp.Repo.update()userendend
Why Confirmation Matters
Without confirmed_at, the generated auth code treats the user as unconfirmed. Tests that log in users will silently fail or return unexpected redirects.
# Bad — user is unconfirmed, login tests may faildef user_fixture(attrs \\ %{}) do{:ok, user} =attrs|> valid_user_attributes()|> MyApp.Accounts.register_user()user # Missing confirmation!end# Good — user is confirmed and ready for auth testsdef user_fixture(attrs \\ %{}) do{:ok, user} =attrs|> valid_user_attributes()|> MyApp.Accounts.register_user(){:ok, user} =user|> Ecto.Changeset.change(%{confirmed_at: DateTime.utc_now(:second)})|> MyApp.Repo.update()userend
Adding Profile Fields Later
For non-auth fields (bio, avatar, display name), create a separate profile_changeset:
# In user.exdef profile_changeset(user, attrs) douser|> cast(attrs, [:bio, :display_name, :avatar_url])|> validate_length(:bio, max: 500)|> validate_length(:display_name, max: 50)end# In accounts.exdef update_user_profile(user, attrs) douser|> User.profile_changeset(attrs)|> Repo.update()end
Testing Auth Customization
describe "register_user/1" dotest "requires username" do{:error, changeset} = Accounts.register_user(%{email: "test@example.com",password: "validpassword123"})assert "can't be blank" in errors_on(changeset).usernameendtest "validates username format" do{:error, changeset} = Accounts.register_user(%{email: "test@example.com",username: "has spaces",password: "validpassword123"})assert "only letters, numbers, and underscores" in errors_on(changeset).usernameendtest "enforces unique username" do%{username: username} = user_fixture(){:error, changeset} = Accounts.register_user(%{email: "other@example.com",username: username,password: "validpassword123"})assert "has already been taken" in errors_on(changeset).usernameendend
See ecto-changeset-patterns skill for advanced changeset composition. See phoenix-liveview-auth skill for on_mount and auth redirect patterns. See testing-essentials skill for comprehensive testing patterns.