Authentication and Authorization
Table of Contents
Overview
In your final project you already know how to build REST APIs and how authentication and authorization work in theory. In this chapter you will apply those ideas to a real, production-style backend.
You will design and implement a realistic authentication and authorization system that can safely run in production, integrate with PostgreSQL and Redis, and support your application’s needs as it grows.
The goal here is not to re-explain basic concepts like “what is a JWT” or “what is RBAC,” but to show how to wire everything together in a complete project, with production-ready decisions and trade-offs.
Requirements for Auth in a Production Project
Before choosing tools or writing code, define what your application actually needs.
Typical requirements for the final project backend:
- Users can:
- Register with email and password.
- Confirm their email address.
- Log in with email and password.
- Log out (invalidate tokens or sessions).
- Request a password reset and set a new password.
- The system must:
- Store passwords securely.
- Issue short-lived access tokens and longer-lived refresh tokens.
- Protect sensitive endpoints with authentication.
- Support at least basic role-based authorization (for example
user,admin). - Provide an admin-only API section.
- Work correctly in a distributed environment with multiple app instances.
List these needs explicitly in your project docs. It will guide your design and help you test the right things.
Designing Your Auth and AuthZ Architecture
Tokens vs Sessions
For this final project, a token-based architecture fits well with a REST API and modern frontends:
- Stateless access tokens.
- Stateful refresh tokens (stored server-side in Redis or database, or implemented with rotation).
A common design:
- Access token
- Format: JWT.
- Lifetime: short, for example 5 to 15 minutes.
- Content: user id, roles, and maybe a small set of claims (never secrets).
- Refresh token
- Format: opaque random string (or JWT) stored server-side.
- Lifetime: longer, for example 7 to 30 days.
- Storage: HTTP-only cookie or secure storage in the client.
- Use: only to obtain a new access token.
Important rule:
Never store plaintext passwords, API keys, or other secrets inside tokens.
Where tokens live
Practical options for your project:
| Location | Pros | Cons | Recommended for project |
|---|---|---|---|
| Local storage | Simple for SPAs, no cookies needed | Vulnerable to XSS script access | Avoid for sensitive apps |
| In-memory (JS state) | Not persisted across refresh, safer than LS | Requires refresh token in cookie or storage | Use for access token |
| HTTP-only cookie | Protected from JS, convenient for browser | Needs CSRF protection if used as auth | Good for refresh tokens |
| Mobile secure storage | Very good for native apps | Platform-specific | Out of scope here |
For a backend-only course project, you can:
- Assume the frontend will store the access token in memory.
- Deliver the refresh token as:
- HTTP-only cookie for browser clients, or
- In the response body for non-browser clients, which then store it safely.
Document this choice in your project README.
Data Model for Users, Roles and Permissions
User table
At minimum your users table needs:
| Column | Type | Description |
|---|---|---|
| id | UUID / bigserial | Primary key |
| text, unique | Login identifier, indexed | |
| password_hash | text | Hashed password (bcrypt, argon2, etc.) |
| is_active | boolean | Soft-activation flag, for bans or deactivation |
| is_superuser | boolean | For full admin permissions |
| created_at | timestamptz | Creation time |
| updated_at | timestamptz | Last update |
| email_verified | boolean | Whether email is verified |
| last_login_at | timestamptz, null | Last successful login |
Keep password_hash separate from any other user data. Never mix plain password with hash in code.
Roles and permissions
For a real production system you might have:
rolestable.permissionstable.role_permissionsjoin table.user_rolestable.
For this project you can choose between:
- Simple roles in the user table
- Columns:
is_superuser, mayberoleenum:user,staff,admin. - Fast to implement, fine for small projects.
- Full RBAC tables
- More flexible but more work and more queries.
For a beginner-friendly final project, a hybrid that uses:
is_superuserflag for full admin access.- A
roleenum foruser,manager,adminif you want more nuance.
is enough to demonstrate authorization patterns without lots of extra complexity.
Password Storage and Authentication Flow
Password hashing
Use a strong, adaptive password hashing algorithm like bcrypt or argon2id.
Implementation rules:
- Never store passwords in plaintext.
- Never use plain SHA-256 or MD5 for passwords.
- Always use a library-designed password hashing function.
With Python you can use:
passlibwith bcrypt.- Or
argon2-cffifor Argon2.
Pseudocode for hashing:
hashed = hash_password(plain_password)
user.password_hash = hashedPseudocode for verification:
if not verify_password(candidate_password, user.password_hash):
raise InvalidCredentialsError()Never log the plain password. You can log that a login failed, but not the credentials.
Login flow
The typical login endpoint:
- Receive
emailandpassword. - Find user by email (case insensitive).
- If user not found or
is_activeis false, respond with a generic error. - Verify password.
- If password correct:
- Generate access and refresh tokens.
- Optionally, store refresh token info (user id, device, expires at) in DB or Redis.
- Update
last_login_at. - Return tokens to the client.
Return the same error message for "user not found" and "wrong password" to avoid revealing which emails exist.
Example response body:
{
"access_token": "<jwt-access>",
"refresh_token": "<opaque-or-jwt-refresh>",
"token_type": "bearer",
"expires_in": 900
}
If you use HTTP-only cookies for refresh tokens, the refresh_token might not appear in the JSON.
Access and Refresh Tokens in Your Project
Designing JWT payloads
Your JWT payload should include only what you need to check authorization quickly.
A minimal example:
{
"sub": "user_id_here",
"exp": 1700000000,
"iat": 1699996400,
"type": "access",
"is_superuser": false,
"role": "user"
}Key fields:
sub: subject, usually user id.exp: expiration timestamp.iat: issued at timestamp.type: "access" or "refresh" if you use JWT refresh tokens.- A few auth-related claims: role or flags.
Important rule:
Validate exp, iat, and type before trusting any other claim in the token.
Signing and verifying tokens
Use a strong secret key for HMAC signing (HS256) and store it in an environment variable, not in code.
For example:
JWT_SECRET_KEYJWT_REFRESH_SECRET_KEY(if you want separate keys)
Use environment variables and your configuration system, not hardcoded literals.
Token creation pseudocode:
def create_access_token(user_id: str, is_superuser: bool, role: str) -> str:
now = datetime.utcnow()
payload = {
"sub": user_id,
"type": "access",
"is_superuser": is_superuser,
"role": role,
"iat": int(now.timestamp()),
"exp": int((now + ACCESS_TOKEN_LIFETIME).timestamp())
}
return encode_jwt(payload, secret=JWT_SECRET_KEY, algorithm="HS256")Always check:
- Signature.
expis in the future.typeis"access"when using it as access token.
Refresh tokens and rotation
Refresh tokens are more sensitive, because they live longer.
To make them safer:
- Give them a unique ID (
jticlaim or random string). - Store them server-side with:
- User id.
- Expiration.
- A status flag (valid, revoked).
- On token refresh:
- Validate refresh token.
- Create a new access token.
- Optionally:
- Invalidate the old refresh token.
- Issue a new refresh token (rotation).
If a refresh token leaks, you want to be able to revoke it quickly.
You can store refresh tokens in:
- PostgreSQL table
user_refresh_tokens. - Or Redis, keyed by a token ID or random string.
Example Redis key pattern:
refresh_token:<token_id> -> {user_id, expires_at}
Integrating Auth with PostgreSQL and Redis
Storing data in PostgreSQL
Use PostgreSQL for:
- Users and their profile data.
- Roles and admin flags.
- Email verification records.
- Password reset tokens.
- Persistent refresh tokens if you want a complete history.
Example PostgreSQL tables:
usersemail_verification_tokenspassword_reset_tokensuser_refresh_tokens(optional if not using Redis)
Design email_verification_tokens like:
| Column | Type | Description |
|---|---|---|
| id | UUID / bigserial | Primary key |
| user_id | FK to users.id | Which user |
| token | text, unique | Random token or signed token id |
| expires_at | timestamptz | Expiration time |
| used_at | timestamptz | When it was used, null if unused |
You can also make token a signed value that contains only the user id and expiry, verified using your secret key. In that case you do not need a separate table.
Storing ephemeral data in Redis
Use Redis for short-lived or frequent operations:
- Rate limiting authentication attempts.
- Blacklist or invalidate specific tokens quickly.
- Storing current active sessions.
- Short TTL email verification or password reset tokens.
Examples:
- Failed login attempts per IP or email:
- Key:
auth:login_attempts:<ip-or-email> - Value: integer count
- TTL: 15 minutes
- Blacklisted JWT IDs:
- Key:
jwt:blacklist:<jti> - Value: "1"
- TTL: until original
exp
Your FastAPI dependency that validates tokens can check Redis for jwt:blacklist:<jti>. If it exists, treat the token as invalid.
Implementing Authentication in the Final Project
Endpoints to implement
At minimum, your final project should include these auth endpoints:
POST /auth/register- Input:
email,password, maybefull_name. - Behavior:
- Create user.
- Hash password.
- Set
email_verifiedto false. - Send verification email (in background).
- Response: success message or created user data (never password hash).
POST /auth/login- Input:
email,password. - Behavior:
- Check credentials.
- Create access and refresh tokens.
- Maybe set refresh token as HTTP-only cookie.
- Response: token pair.
POST /auth/refresh- Input: refresh token (body or cookie).
- Behavior:
- Validate refresh token and its status.
- Optionally rotate refresh token.
- Issue new access token.
- Response: new tokens.
POST /auth/logout- Input: refresh token.
- Behavior:
- Invalidate refresh token in DB or Redis.
- Response: success message.
POST /auth/verify-email- Input: verification token (usually passed in URL query).
- Behavior:
- Validate token.
- Mark user email as verified.
- Response: success message.
POST /auth/request-password-reset- Input:
email. - Behavior:
- If user exists, send reset email with one-time token.
- Response: generic success message (same for existing or non-existing email).
POST /auth/reset-password- Input:
token,new_password. - Behavior:
- Validate token.
- Update password hash.
- Response: success message.
These endpoints should be covered by tests in your final project.
FastAPI dependencies for auth
Centralize token decoding and user loading into reusable dependencies.
Common pattern:
get_current_user:- Reads
Authorization: Bearer <token>header. - Validates JWT.
- Loads user from database.
- Checks
is_active. get_current_active_user:- Wraps
get_current_user. - Additionally checks
user.is_activeanduser.email_verified.
Pseudocode:
async def get_current_user(token: str = Depends(oauth2_scheme),
session: AsyncSession = Depends(get_db)):
payload = decode_jwt(token)
user_id = payload["sub"]
user = await get_user_by_id(session, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="Inactive or invalid user")
return userUse these dependencies in your routers to keep the code DRY.
Authorization Rules in a Real Project
Protecting endpoints
For each API endpoint in the final project, you need to decide:
- Public, no auth required.
- Authenticated user required.
- Admin or elevated role required.
- Resource owner only (for example, only owner can update a task).
Examples:
- Public:
POST /auth/registerPOST /auth/loginPOST /auth/request-password-reset- Authenticated:
GET /meGET /orders/myPOST /tasks- Admin:
GET /admin/usersDELETE /admin/users/{id}
Implementing role checks
Use separate dependencies for role-based checks, for example:
get_current_admin:- Uses
get_current_user. - Checks
user.is_superuseroruser.role == "admin".
Pseudocode:
async def get_current_admin(user: User = Depends(get_current_user)):
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
return userThen annotate admin-only endpoints:
@router.get("/admin/users")
async def list_users(admin: User = Depends(get_current_admin)):
...This keeps authorization logic at the edges of your API, not inside business logic.
Resource ownership
For operations on resources like tasks, orders, or files, you often need to ensure the user is the owner.
Pattern:
- Load resource from database by its id.
- Check if
resource.owner_id == current_user.id. - If not, return
403 Forbidden.
Example:
async def get_task_or_404(task_id: int, session: AsyncSession):
task = await session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.put("/tasks/{task_id}")
async def update_task(
task_id: int,
data: TaskUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db)
):
task = await get_task_or_404(task_id, session)
if task.owner_id != user.id and not user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
...
Avoid exposing details about whether the resource exists for unauthorized users. You may choose between returning 404 or 403 based on your threat model, but be consistent.
Protecting Auth in a Production Environment
Rate limiting login and reset endpoints
Authentication endpoints are frequent targets for abuse, such as brute force attacks.
Use Redis-based rate limiting:
- Limit by IP.
- Optionally also limit by email or username.
Example rule:
Allow at most 5 failed login attempts per IP per 15 minutes.
Implementation idea:
- When a login fails:
- Increment
auth:login:fail:<ip>in Redis. - Set TTL to 15 minutes if key was new.
- Before processing a login:
- Check that counter is below your threshold.
- If above, return
429 Too Many Requests.
You can use similar rules for:
- Password reset requests.
- Email verification link requests.
Protect tokens in transit
Configure your production environment as follows:
- Only use HTTPS for all requests.
- Set
Secureflag on cookies that carry refresh tokens. - Use
SameSiteattributes correctly, typicallyLaxorStrictfor your use case.
When behind a reverse proxy like Nginx or Traefik:
- Ensure correct handling of
X-Forwarded-Proto. - Configure your app to treat incoming traffic as HTTPS when it is.
Token invalidation and logout
In a purely stateless JWT setup, logout is not straightforward. For a production system you should support some level of invalidation.
Options:
- Short-lived access tokens, long-lived refresh tokens
- Access tokens expire soon.
- Logout simply deletes or revokes the refresh token on the server.
- Blacklist (denylist) JWT IDs in Redis
- Each JWT has a unique
jticlaim. - On logout, add
jtito Redis with TTL equal to token’s remaining life. - Check against Redis in the auth dependency.
Even if you choose a simpler approach for your project, document your token invalidation strategy clearly.
Email Verification and Password Reset
Email verification flow
In the final project, your verification flow could be:
POST /auth/registercreates user withemail_verified = false.- It creates a verification token:
- Random string or signed JWT with
user_idand expiry. - TTL, for example 24 hours.
- Send email with verification link:
- URL:
https://frontend-app/verify-email?token=<token>. - Frontend calls
POST /auth/verify-emailwith that token. - Backend:
- Verifies token.
- Ensures not used or expired.
- Sets
email_verified = true.
Do not leak whether a token is valid or not in a way that helps an attacker enumerate users. Generic error messages are usually safer.
Password reset flow
Very similar idea:
POST /auth/request-password-reset:- If user exists:
- Generate one-time reset token.
- Store it with TTL, for example 1 hour.
- Send email with reset link.
- Always return 200 OK with generic message.
POST /auth/reset-password:- Input:
token,new_password. - Validate token.
- Update password hash.
- Invalidate that token so it cannot be reused.
Tokens for reset should be:
- Random with enough entropy (for example 32 bytes base64).
- Single-use.
- Time-limited.
Testing Authentication and Authorization
For the final project you should have automated tests that cover the critical paths.
What to test
Suggested test list:
- Registration:
- Successful registration.
- Duplicate email fails.
- Password minimum length enforced.
- Login:
- Successful login returns a valid access token.
- Invalid password fails.
- Inactive user cannot log in.
- Access token:
- Protected endpoint returns 401 without token.
- Protected endpoint returns 200 with valid token.
- Expired token returns 401.
- Token with invalid signature returns 401.
- Refresh:
- Using valid refresh token returns new access token.
- Revoked refresh token fails.
- Email verification:
- Unverified user cannot access endpoints that require verified email (if you enforce this).
- Verifying email flips
email_verifiedflag. - Password reset:
- Requesting password reset produces a token.
- Using token updates password.
- Token cannot be reused.
- Authorization:
- Normal user cannot access admin endpoints.
- Admin user can access admin endpoints.
- User cannot update resources of other users.
Using fixtures and helpers
Create helper functions for tests:
create_user(...).login_user(...)that returns access token.auth_client(user)that returns a test client with Authorization header already set.
This makes your tests easier to read and maintain.
Putting It All Together
In your final project repository, you should end up with:
- A dedicated
authmodule or package, with: - Routers for
/auth/*. - Service functions for token creation, password hashing, and email workflows.
- Dependencies for
get_current_user,get_current_admin, etc. - Database models for:
User.- Optional
Role,Permission,UserRefreshToken,EmailVerificationToken,PasswordResetToken. - Integration with:
- PostgreSQL for persistent auth data.
- Redis for rate limiting and token invalidation if you choose to implement it.
- Configuration:
- Strong secrets from environment variables.
- Token expiry and security settings controlled via config.
- Tests:
- Covering authentication and authorization flows and most critical error cases.
Keep the design clear and documented. The goal is not only to make your app secure, but also maintainable and understandable for someone reading your code or reviewing your project as part of a hiring process.
Views: 5
KAHIBARO