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.
# 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 --reloadProduction 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:
export DATABASE_URL="postgresql+psycopg://user:pass@localhost/app"
export SECRET_KEY="$(openssl rand -hex 32)"
export ACCESS_TOKEN_EXPIRE_MINUTES="60"Fast install:
pip install fastapi uvicorn sqlalchemy passlib[bcrypt] python-jose[email-validator] psycopg[binary]
uvicorn main:app --reloadRequest flow diagram: register -> validate -> hash password -> store user
Request flow diagram: login -> verify hash -> issue session/token -> access protected route
What’s happening
Authentication has two main paths:
-
Registration
- Accept email and password
- Normalize email
- Validate password
- Hash password
- Store user record
-
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:
- Password Hashing and Security Best Practices
- Session Management vs JWT (When to Use What)
- Protecting Routes and APIs
Step-by-step implementation
1) Create the users table
Minimum schema:
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:
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_activefor blocking disabled users later
2) Normalize email consistently
Do this on both registration and login.
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.
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:
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:
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=trueSecure=truein productionSameSite=LaxorStrictunless cross-site flow requires otherwise
Example Flask session setup:
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:
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:
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:
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:
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.
import os
SECRET_KEY = os.environ["SECRET_KEY"]
DATABASE_URL = os.environ["DATABASE_URL"]Generate a secret:
openssl rand -hex 3211) Test the flow
Register:
curl -i -X POST http://localhost:8000/register \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"testpass123"}'Login:
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:
curl -i http://localhost:8000/me \
-H 'Authorization: Bearer YOUR_TOKEN_HERE'Inspect DB:
sqlite3 app.db "SELECT id, email, password_hash, is_active FROM users;"Verify hashing works:
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.emailcauses 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
Authorizationheader - 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
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
sqlite3 app.db "SELECT id, email, password_hash, is_active FROM users;"Test register directly
curl -i -X POST http://localhost:8000/register \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"testpass123"}'Test login directly
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
curl -i http://localhost:8000/me \
-H 'Authorization: Bearer YOUR_TOKEN_HERE'Decode token claims locally
python -c "from jose import jwt; print(jwt.get_unverified_claims('YOUR_TOKEN_HERE'))"Check logs
grep -E 'login|register|401|403' /var/log/app.logCheck runtime env
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
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
403where appropriate
For broader launch validation:
Related guides
- Password Hashing and Security Best Practices
- Session Management vs JWT (When to Use What)
- Protecting Routes and APIs
- Stripe Subscription Setup (Step-by-Step)
- SaaS Production Checklist
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.