Implement User Authentication (Login/Register)

The essential playbook for implementing implement user authentication (login/register) in your SaaS.

This page outlines a minimal, production-safe login/register implementation for Flask or FastAPI. It focuses on email/password auth for MVPs: user model, password hashing, registration, login, session or JWT issuance, protected routes, and baseline security controls.

Use this as the starting point before adding email verification, password reset, OAuth, or RBAC.

Quick Fix / Quick Setup

Use this FastAPI baseline if you need a working login/register flow with hashed passwords and JWT-based auth.

python
# FastAPI example: minimal login/register setup with SQLAlchemy + passlib + JWT

from datetime import datetime, timedelta
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
from passlib.context import CryptContext
from jose import jwt, JWTError
from sqlalchemy import Column, Integer, String, Boolean, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session

DATABASE_URL = "sqlite:///./app.db"
SECRET_KEY = "change-me-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")
app = FastAPI()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True, nullable=False)
    password_hash = Column(String, nullable=False)
    is_active = Column(Boolean, default=True)

Base.metadata.create_all(bind=engine)

class RegisterIn(BaseModel):
    email: EmailStr
    password: str


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


def hash_password(password: str) -> str:
    return pwd_context.hash(password)


def verify_password(password: str, password_hash: str) -> bool:
    return pwd_context.verify(password, password_hash)


def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=60))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


def get_user_by_email(db: Session, email: str):
    return db.query(User).filter(User.email == email.lower()).first()


@app.post("/register")
def register(payload: RegisterIn, db: Session = Depends(get_db)):
    email = payload.email.lower().strip()
    if len(payload.password) < 8:
        raise HTTPException(status_code=400, detail="Password too short")
    if get_user_by_email(db, email):
        raise HTTPException(status_code=409, detail="Email already registered")

    user = User(email=email, password_hash=hash_password(payload.password))
    db.add(user)
    db.commit()
    db.refresh(user)
    return {"id": user.id, "email": user.email}


@app.post("/login")
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = get_user_by_email(db, form_data.username.lower().strip())
    if not user or not verify_password(form_data.password, user.password_hash):
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
    token = create_access_token({"sub": str(user.id), "email": user.email})
    return {"access_token": token, "token_type": "bearer"}


@app.get("/me")
def me(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return {"user_id": payload.get("sub"), "email": payload.get("email")}
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

# Install:
# pip install fastapi uvicorn sqlalchemy passlib[bcrypt] python-jose[email-validator]
# Run:
# uvicorn main:app --reload

Production minimums:

  • Move secrets to environment variables
  • Add rate limiting
  • Add CSRF protection for cookie-based auth
  • Set secure cookie flags if using sessions
  • Add email verification
  • Add password reset
  • Prefer a real database over SQLite in production

Example environment config:

bash
export DATABASE_URL="postgresql+psycopg://user:pass@localhost/app"
export SECRET_KEY="$(openssl rand -hex 32)"
export ACCESS_TOKEN_EXPIRE_MINUTES="60"

Fast install:

bash
pip install fastapi uvicorn sqlalchemy passlib[bcrypt] python-jose[email-validator] psycopg[binary]
uvicorn main:app --reload
register
validate
hash password
store user

Request flow diagram: register -> validate -> hash password -> store user

login
verify hash
issue session/token
access protected route

Request flow diagram: login -> verify hash -> issue session/token -> access protected route

What’s happening

Authentication has two main paths:

  1. Registration

    • Accept email and password
    • Normalize email
    • Validate password
    • Hash password
    • Store user record
  2. Login

    • Accept email and password
    • Normalize email
    • Fetch user
    • Verify password hash
    • Issue session or token

Core rules:

  • Never store raw passwords
  • Use email as the primary identity key unless you have a strong reason not to
  • Add a database unique constraint on email
  • Pick one transport first:
    • Sessions/cookies for server-rendered apps
    • JWT/bearer tokens for API-first apps

For most MVPs, email/password auth is the fastest path before adding OAuth.

See also:

Step-by-step implementation

1) Create the users table

Minimum schema:

sql
CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  is_active BOOLEAN NOT NULL DEFAULT TRUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

If using SQLAlchemy:

python
from sqlalchemy import Column, BigInteger, Text, Boolean, DateTime, func

class User(Base):
    __tablename__ = "users"

    id = Column(BigInteger, primary_key=True)
    email = Column(Text, unique=True, index=True, nullable=False)
    password_hash = Column(Text, nullable=False)
    is_active = Column(Boolean, default=True, nullable=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

Requirements:

  • Unique email constraint at DB level
  • Indexed email lookups
  • is_active for blocking disabled users later

2) Normalize email consistently

Do this on both registration and login.

python
def normalize_email(email: str) -> str:
    return email.strip().lower()

Bad pattern:

  • Lowercasing only on registration
  • Raw lookup on login

Good pattern:

  • Normalize on write
  • Normalize on every lookup

3) Hash passwords correctly

Use bcrypt or Argon2 through a maintained library.

python
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(password: str, password_hash: str) -> bool:
    return pwd_context.verify(password, password_hash)

Do not use:

  • SHA256
  • MD5
  • custom salt logic
  • reversible encryption

Detailed hashing guidance:

4) Create the register endpoint

Example FastAPI registration:

python
from fastapi import HTTPException

@app.post("/register")
def register(payload: RegisterIn, db: Session = Depends(get_db)):
    email = normalize_email(payload.email)

    if len(payload.password) < 8:
        raise HTTPException(status_code=400, detail="Password too short")

    existing = db.query(User).filter(User.email == email).first()
    if existing:
        raise HTTPException(status_code=409, detail="Email already registered")

    user = User(
        email=email,
        password_hash=hash_password(payload.password),
        is_active=True,
    )
    db.add(user)
    db.commit()
    db.refresh(user)

    return {"id": user.id, "email": user.email}

Required behavior:

  • Validate email shape
  • Validate minimum password rule
  • Enforce unique email
  • Hash before save
  • Never return password hash

5) Create the login endpoint

Example FastAPI login:

python
from fastapi.security import OAuth2PasswordRequestForm

@app.post("/login")
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    email = normalize_email(form_data.username)

    user = db.query(User).filter(User.email == email).first()
    if not user or not verify_password(form_data.password, user.password_hash):
        raise HTTPException(status_code=401, detail="Invalid credentials")

    if not user.is_active:
        raise HTTPException(status_code=403, detail="Account disabled")

    token = create_access_token({"sub": str(user.id), "email": user.email})
    return {"access_token": token, "token_type": "bearer"}

Requirements:

  • Use a generic invalid credentials error
  • Do not reveal whether email exists
  • Block inactive users separately if needed

6) Issue sessions or JWT

Pick one primary method.

Option A: Sessions

Best for:

  • server-rendered apps
  • simpler browser auth
  • apps that can rely on cookies

Cookie requirements:

  • HttpOnly=true
  • Secure=true in production
  • SameSite=Lax or Strict unless cross-site flow requires otherwise

Example Flask session setup:

python
from flask import Flask, session, request, jsonify
from werkzeug.security import check_password_hash

app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"

@app.post("/login")
def login():
    data = request.form or request.json
    email = normalize_email(data["email"])
    password = data["password"]

    user = get_user_by_email(email)
    if not user or not verify_password(password, user.password_hash):
        return jsonify({"detail": "Invalid credentials"}), 401

    session["user_id"] = user.id
    return jsonify({"ok": True})

If using cookies, also review:

  • CSRF handling
  • domain/path settings
  • proxy HTTPS forwarding

Option B: JWT

Best for:

  • API-first backends
  • mobile clients
  • SPAs calling APIs directly

JWT guidance:

  • Keep access tokens short-lived
  • Validate signature and expiry on every protected route
  • Consider refresh tokens only if needed
  • Avoid long-lived access tokens

Example token helper:

python
from datetime import datetime, timedelta
from jose import jwt

ALGORITHM = "HS256"
SECRET_KEY = os.environ["SECRET_KEY"]

def create_access_token(data: dict, expires_minutes: int = 60) -> str:
    payload = data.copy()
    payload["exp"] = datetime.utcnow() + timedelta(minutes=expires_minutes)
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

Tradeoffs:

7) Protect routes

You need one consistent current-user resolver.

Example FastAPI protected route:

python
from jose import JWTError, jwt
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")

def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(status_code=401, detail="Invalid token")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    user = db.query(User).filter(User.id == int(user_id)).first()
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    if not user.is_active:
        raise HTTPException(status_code=403, detail="Account disabled")
    return user

@app.get("/me")
def me(current_user: User = Depends(get_current_user)):
    return {"id": current_user.id, "email": current_user.email}

Route protection details:

8) Add logout

Sessions:

  • Clear the server-side session or cookie state

Example Flask logout:

python
from flask import session, jsonify

@app.post("/logout")
def logout():
    session.clear()
    return jsonify({"ok": True})

JWT:

  • Stateless access token logout usually means deleting token client-side
  • If using refresh tokens, revoke or delete refresh token state server-side

9) Add rate limiting

This is required in production.

FastAPI example with SlowAPI:

python
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@app.post("/login")
@limiter.limit("5/minute")
def login(...):
    ...

Also rate-limit:

  • /register
  • password reset endpoints
  • email verification resend endpoints

10) Move secrets out of source

Use environment variables.

python
import os

SECRET_KEY = os.environ["SECRET_KEY"]
DATABASE_URL = os.environ["DATABASE_URL"]

Generate a secret:

bash
openssl rand -hex 32

11) Test the flow

Register:

bash
curl -i -X POST http://localhost:8000/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","password":"testpass123"}'

Login:

bash
curl -i -X POST http://localhost:8000/login \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=user@example.com&password=testpass123'

Protected route:

bash
curl -i http://localhost:8000/me \
  -H 'Authorization: Bearer YOUR_TOKEN_HERE'

Inspect DB:

bash
sqlite3 app.db "SELECT id, email, password_hash, is_active FROM users;"

Verify hashing works:

bash
python -c "from passlib.context import CryptContext; pwd=CryptContext(schemes=['bcrypt'], deprecated='auto'); h=pwd.hash('testpass123'); print(h); print(pwd.verify('testpass123', h))"

Common causes

Most broken login/register implementations fail for a small set of repeatable reasons.

  • Missing unique constraint on users.email causes duplicate accounts
  • Password verification fails because hashes were generated with a different algorithm or library settings
  • Cookies are not persisted due to wrong Secure, SameSite, domain, or proxy HTTPS configuration
  • JWTs fail to validate because the secret, algorithm, or expiry handling is inconsistent across services
  • CORS or credential settings block cookie-based auth between frontend and API
  • Email normalization is inconsistent, causing failed lookups or duplicate registrations
  • Authentication middleware is not applied to protected routes
  • Environment variables for secret keys are missing in production

Additional failure patterns:

  • Reverse proxy strips Authorization header
  • App does not trust forwarded HTTPS headers
  • Frontend sends JSON while backend expects form data for /login
  • Inactive users are not handled consistently
  • Password hashes were stored raw during early local testing

Debugging tips

Run checks in this order.

Check password hashing

bash
python -c "from passlib.context import CryptContext; pwd=CryptContext(schemes=['bcrypt'], deprecated='auto'); h=pwd.hash('testpass123'); print(h); print(pwd.verify('testpass123', h))"

Check stored user row

bash
sqlite3 app.db "SELECT id, email, password_hash, is_active FROM users;"

Test register directly

bash
curl -i -X POST http://localhost:8000/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","password":"testpass123"}'

Test login directly

bash
curl -i -X POST http://localhost:8000/login \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=user@example.com&password=testpass123'

Test protected route

bash
curl -i http://localhost:8000/me \
  -H 'Authorization: Bearer YOUR_TOKEN_HERE'

Decode token claims locally

bash
python -c "from jose import jwt; print(jwt.get_unverified_claims('YOUR_TOKEN_HERE'))"

Check logs

bash
grep -E 'login|register|401|403' /var/log/app.log

Check runtime env

bash
env | grep -E 'SECRET|JWT|SESSION|DATABASE'

Debugging checklist:

  • Confirm email is normalized before lookup
  • Confirm stored field is password_hash, not raw password
  • Confirm same hashing library and settings are used for hash/verify
  • Confirm frontend sends the expected content type
  • Confirm proxy forwards HTTPS and auth headers
  • Confirm cookie flags match actual deployment behavior
  • Confirm secret exists in runtime container or process
  • Confirm route protection is mounted and used
login failed
user lookup
hash verify
token/session issue
protected route check

Decision flowchart

Checklist

Use this before deploying auth to production.

Checklist

  • Users table includes unique email constraint
  • Passwords are hashed with bcrypt or Argon2
  • Emails are normalized on both register and login
  • Registration validates password length and input format
  • Login returns generic invalid credentials errors
  • Sessions or tokens are issued only after successful password verification
  • Protected routes consistently require authenticated identity
  • Logout clears session or token state correctly
  • Secrets come from environment variables
  • HTTPS is enabled in production
  • Rate limiting is enabled for auth endpoints
  • Tests cover success and failure cases
  • Cookie flags are correct if using session auth
  • Reverse proxy forwards HTTPS/auth headers correctly
  • Disabled users return 403 where appropriate

For broader launch validation:

Related guides

FAQ

Should I use sessions or JWT for a small SaaS?

Use sessions for traditional web apps and JWT or bearer tokens for API-first clients. Pick one primary pattern first to reduce complexity.

Do I need email verification on day one?

Not strictly, but it is strongly recommended before enabling sensitive flows like billing changes or password reset.

What password hashing algorithm should I use?

Use bcrypt or Argon2 through a maintained library. Do not build your own hashing logic or store raw passwords.

Why does login work locally but fail in production?

Most often due to missing secrets, cookie flags, reverse proxy HTTPS handling, or different environment configuration.

What is the minimum secure auth implementation for MVP?

Unique email, hashed passwords, secure secret management, protected routes, logout, HTTPS, and rate limiting on login/register endpoints.

Final takeaway

A good login/register implementation is mostly about correct defaults:

  • unique email at the database layer
  • strong password hashing
  • consistent email normalization
  • secure session or token handling
  • protected routes
  • production-safe secret management
  • HTTPS and rate limiting

Keep the first version small. Add email verification, password reset, OAuth, RBAC, and audit logging as separate layers after the baseline is stable.