Skip to content

Exercise: Build a Registration Endpoint

You'll:

  • implement POST /auth/register in the auth router
  • write two tests: one that registers a user, one that registers and then logs in
  • generate a development JWT_SECRET_KEY and add it to .env
  • generate and apply the migration that creates the users table
  • register a new user through the docs UI and confirm the full flow works

The auth flow has three steps:

  • A user registers with an email and password. The server hashes the password and stores the user.
  • The user logs in via POST /auth/token. The server verifies the password and returns a signed JWT (JSON Web Token).
  • The client sends that JWT in the Authorization header on every subsequent request. Protected endpoints verify the token using JWT_SECRET_KEY to identify the caller.

The login endpoint, the security helpers (verify_password, create_access_token, get_current_user), the User model, CurrentUserDep, the crud.create_user helper, and the protected project/task endpoints are all in place on this chapter's branch. You build the registration route and its tests.

Before You Start

Set up the starting state for this exercise:

shell
git fetch
git checkout -b my-ch10 --no-track origin/ch10
uv sync
Files that changed in this chapter:
  • pyproject.toml (modified, adds pwdlib[argon2], pyjwt, and email-validator)
  • src/release_tracker/config.py (modified, adds the jwt_secret_key setting)
  • src/release_tracker/models.py (modified, adds User, UserBase, UserCreate, UserRead, AccessToken)
  • src/release_tracker/crud.py (modified, adds get_user_by_email and create_user)
  • src/release_tracker/security.py (new, password hashing, JWT creation, token validation)
  • src/release_tracker/dependencies.py (modified, adds CurrentUserDep)
  • src/release_tracker/routers/auth.py (modified, login and /me are provided; you'll add /register)
  • src/release_tracker/routers/projects.py (modified, write routes require CurrentUserDep)
  • src/release_tracker/routers/tasks.py (modified, write routes require CurrentUserDep)
  • src/release_tracker/main.py (modified, includes the auth router)
  • tests/conftest.py (modified, sets JWT_SECRET_KEY for tests, adds make_user, auth_headers, auth_client fixtures)
  • tests/test_auth.py (modified, 7 login/me tests provided, 2 registration tests stubbed)
  • scripts/seed.py (modified, also seeds a demo user)

Step 1: Generate a JWT_SECRET_KEY

The new jwt_secret_key setting has min_length=32 and no default. Without one in the environment, the application won't start.

Generate a strong key:

shell
uv run python -c "import secrets; print(secrets.token_urlsafe(32))"

Add it to your .env:

.env
DATABASE_URL=postgresql+psycopg://release_tracker:release_tracker@localhost:5432/release_tracker
DEBUG=true
JWT_SECRET_KEY=<paste the generated value here>

Confirm .env is in .gitignore before going further.

Step 2: Generate and Apply the Migration

The new User model needs a users table. Generate the migration and apply it:

shell
uv run alembic revision --autogenerate -m "add users table"
uv run alembic upgrade head

alembic current should print the revision ID for add_users_table.

Step 3: Implement the Register Endpoint

Open src/release_tracker/routers/auth.py.

The login endpoint and /me are provided. At the bottom, a register function is stubbed:

src/release_tracker/routers/auth.py
@router.post(
    "/register",
    response_model=models.UserRead,
    status_code=status.HTTP_201_CREATED,
)
def register(payload: models.UserCreate, session: SessionDep) -> Any:
    # TODO: Create a new user account.
    #
    # Use crud.create_user() with the email and password from the payload.
    # Return the created user.
    #
    # Don't worry about duplicate emails — the IntegrityError handler
    # from main.py catches that and returns a 409.
    raise NotImplementedError

Look at how crud.create_user is called in tests/conftest.py (the make_user fixture) if you want to see what arguments it expects.

Step 4: Write the Registration Tests

Open tests/test_auth.py. The 7 login and /me tests are provided. Two test functions at the bottom are stubbed:

tests/test_auth.py
def test_register_creates_user(client: TestClient) -> None:
    # TODO: Register a new user via POST /auth/register with a JSON body
    # containing "email" and "password".
    #
    # Assert:
    #   - status code is 201
    #   - response body contains the email you sent
    #   - response body has is_active == True
    #   - hashed_password is NOT in the response
    pass


def test_register_then_login(client: TestClient) -> None:
    # TODO: Register a new user, then log in with the same credentials
    # via POST /auth/token (form data, not JSON).
    #
    # Assert:
    #   - the login response has status 200
    #   - the response contains an access_token
    pass

Remember: /auth/register accepts JSON, but /auth/token accepts form data (data={}, not json={}).

Step 5: Run the Tests

shell
uv run pytest

All tests should pass: the project tests, the task tests, the 7 provided auth tests, plus the 2 you wrote.

Step 6: Try It in /docs

Reseed the database (the seed script creates a demo user) and start the server:

shell
uv run python -m scripts.seed
uv run fastapi dev

Open http://localhost:8000/docs, then:

  1. Try POST /auth/register with a new email and password. Expect a 201 with the user's email and ID.
  2. Try registering the same email again. Expect a 409 (the IntegrityError handler catches the duplicate).
  3. Click Authorize at the top of the page. Enter the email and password you just registered with. Click Authorize, then Close.
  4. Try POST /projects/ with a valid body. Expect a 201.
  5. Click the lock icon to log out, then retry the same POST. Expect a 401.

Success

If pytest is green and you can register, log in, and hit protected endpoints from the docs UI, the registration endpoint is wired correctly and the full auth flow works end-to-end.

Troubleshooting Tips

  • Server won't start; Pydantic complains about JWT_SECRET_KEY:

    The setting has min_length=32 and no default. The .env either doesn't exist, isn't being loaded (wrong working directory), or has a key shorter than 32 characters.

  • POST /auth/register returns 500 instead of 201:

    Check that crud.create_user is being called with keyword arguments (email= and password=). Positional arguments in the wrong order will pass the email as the password.

  • POST /auth/register returns 422:

    The request body must be JSON ({"email": "...", "password": "..."}), not form data. Unlike /auth/token, the register endpoint uses a Pydantic model, not OAuth2PasswordRequestForm.

  • Duplicate registration returns 500 instead of 409:

    The IntegrityError handler in main.py should already be in place from the chapter branch. Confirm main.py has the exception handler that catches IntegrityError and returns a 409.

  • Seed script fails with UniqueViolation or UndefinedTable:

    The database carries forward across branch switches, so data from earlier chapters can conflict with the seed script. Reset to a clean database and re-run:

    shell
    docker compose down -v && docker compose up -d
    uv run alembic upgrade head
    uv run python -m scripts.seed
    

    The -v flag deletes the Postgres data volume, wiping everything. This is fine for local development but never appropriate for a shared or production database.

Compare with the Solution

shell
git diff solutions/ch10 -- src/ tests/