JWT Authentication Architecture¶
Industry-standard JWT authentication system using Pattern A (JWT access tokens + opaque refresh tokens) for secure, scalable, and revocable user authentication.
Overview¶
Dashtam implements Pattern A JWT authentication, the industry-standard approach used by Auth0, GitHub, Google, and 95% of production systems. This pattern combines:
- JWT Access Tokens (stateless, short-lived)
- Opaque Refresh Tokens (stateful, long-lived)
This architecture provides secure, scalable authentication for the Dashtam financial data aggregation platform, supporting multi-device sessions, email verification, password reset, and session management.
Context¶
Operating Environment¶
Dashtam is a FastAPI-based REST API platform that requires:
- Stateless API design: Horizontally scalable backend services
- Multi-device support: Users access from web, mobile, multiple browsers
- Async operations: All authentication flows use async/await patterns
- Database-backed security: PostgreSQL stores user accounts and tokens
- Email integration: AWS SES for verification and password reset emails
System Constraints¶
- No server-side sessions: FastAPI runs stateless for scalability
- JWT signature verification: Access tokens validated without database lookup
- Token revocation requirement: Users must be able to logout and revoke sessions
- Email verification mandatory: Prevents fake account creation
- Password security: Strong password requirements and bcrypt hashing
Key Requirements¶
- Security: Protect user credentials and tokens from compromise
- Performance: Minimize database lookups for API request authentication
- User experience: Seamless login, remember-me functionality, easy logout
- Compliance: Industry-standard security practices (OWASP, OAuth 2.0)
- Revocability: Ability to revoke sessions on logout or password reset
Architecture Goals¶
- Industry Standard Compliance - Use proven patterns adopted by 95% of production systems (Auth0, GitHub, Google)
- Security First - Implement token hashing, short-lived access tokens, revocable refresh tokens
- Simplicity - Choose the simpler, safer Pattern A over more complex alternatives
- Performance at Scale - Stateless access tokens enable horizontal scaling without database bottlenecks
- Easy Revocation - Opaque refresh tokens are simpler to revoke than JWT-based alternatives
- Developer Experience - Clear separation between stateless (access) and stateful (refresh) tokens
Design Decisions¶
Decision 1: Pattern A - JWT Access + Opaque Refresh Tokens¶
Decision: Use JWT for access tokens (stateless, 30-minute TTL) and opaque random strings for refresh tokens (stateful, 30-day TTL).
The Two-Token System¶
┌─────────────────────────────────────────────────────────────┐
│ PATTERN A (Industry Standard) │
└─────────────────────────────────────────────────────────────┘
ACCESS TOKEN (JWT):
├─ Format: JSON Web Token (eyJhbGciOi...)
├─ Lifetime: 30 minutes (short-lived)
├─ Storage: Client memory (not localStorage!)
├─ Purpose: Authenticate API requests
├─ Validation: Signature verification only
├─ Database: No lookup required (stateless)
└─ Contains: user_id, email, expiration
REFRESH TOKEN (Opaque):
├─ Format: Random string (a8f4e2d9c1b7...)
├─ Lifetime: 30 days (long-lived)
├─ Storage: Client httpOnly cookie or secure storage
├─ Purpose: Obtain new access tokens
├─ Validation: Hash lookup in database
├─ Database: Required for validation (stateful)
└─ Contains: Only random bytes (no claims)
Why This Works¶
| Aspect | Access Token (JWT) | Refresh Token (Opaque) |
|---|---|---|
| Speed | Fast (no DB lookup) | Slower (DB lookup) |
| Use Frequency | Every API call | Rarely (every 30 min) |
| Revocation | Not revocable* | Easily revocable |
| Security | Signature-based | Hash-based |
| Complexity | Higher (JWT) | Lower (random string) |
*Access tokens can't be revoked but expire quickly (30 min)
Rationale¶
Benefits of Pattern A:
- ✅ Industry Standard - Used by Auth0, GitHub, Google, Stripe, AWS Cognito
- ✅ Simpler & Safer - Easier to implement correctly
- ✅ Better Security - No JWT complexity for refresh tokens
- ✅ Proven at Scale - Battle-tested in production systems
- ✅ Easier to Revoke - Opaque tokens are simpler to manage
Alternatives Considered¶
Pattern B: JWT Access + JWT Refresh (❌ Rejected)¶
Pros:
- No database lookup if not validating hash
- Can include claims in refresh token
Cons:
- ❌ More complex to implement securely
- ❌ Must validate JWT hash against DB (negates benefit)
- ❌ Easy to implement insecurely (security hole)
- ❌ Mixing stateless/stateful incorrectly
- ❌ JTI is redundant with DB record ID
Why We Rejected It: The original implementation used Pattern B but forgot to validate the hash, creating a security vulnerability. Pattern A is simpler and industry-standard.
Trade-offs¶
Accepted Trade-offs:
- Refresh token requires database lookup - Acceptable because refresh happens infrequently (every 30 min vs every API call)
- Access tokens cannot be immediately revoked - Acceptable with 30-minute TTL; industry-standard behavior
- Refresh tokens stored in database - Required for revocation capability; enables session management
Benefits Gained:
- API performance - Access token validation is pure signature verification (no DB)
- Horizontal scalability - Stateless access tokens enable load balancing
- Security - Token hashing protects against database compromise
- Simplicity - Opaque tokens are simpler than JWT for refresh use case
Components¶
1. Access Token (JWT)¶
Purpose: Authenticate API requests
Structure:
{
"sub": "123e4567-e89b-12d3-a456-426614174000", // user_id
"email": "user@example.com",
"type": "access",
"exp": 1696453200, // 30 minutes from issue
"iat": 1696451400 // issued at
}
Usage:
# Every API request
curl -H "Authorization: Bearer eyJhbGci..." https://api.dashtam.com/api/v1/providers
Lifecycle:
- Generated at login
- Used for all API requests (30 min)
- Expires automatically
- Client requests new one using refresh token
2. Refresh Token (Opaque)¶
Purpose: Obtain new access tokens without re-login
Structure:
a8f4e2d9c1b7f6e3d2c8b4a1e9f7d6c5e4d3c2b1a9f8e7d6c5b4a3e2d1c0b9a8
(64 character random URL-safe string)
Storage (Database):
RefreshToken(
id=UUID("..."),
user_id=UUID("..."),
token_hash="$2b$12$...", # bcrypt hash of token
expires_at=datetime(2025, 11, 04), # 30 days
revoked_at=None,
is_revoked=False,
device_info="Chrome 118.0 on macOS",
ip_address="192.168.1.100"
)
Usage:
# When access token expires
curl -X POST https://api.dashtam.com/api/v1/auth/refresh \
-d '{"refresh_token": "a8f4e2d9c1b7..."}'
Lifecycle:
- Generated at login (hashed in DB)
- Stored securely by client
- Used to get new access token (once per 30 min)
- Revoked at logout
3. Email Verification Token (Opaque)¶
Purpose: Verify user email address
Structure: Similar to refresh token (random string)
Lifecycle:
- Generated at registration
- Sent via email (plain text)
- Stored as hash in DB
- One-time use
- Expires in 24 hours
4. Password Reset Token (Opaque)¶
Purpose: Reset forgotten password
Structure: Similar to refresh token (random string)
Lifecycle:
- Generated on password reset request
- Sent via email (plain text)
- Stored as hash in DB
- One-time use
- Expires in 1 hour (security!)
Responsibilities:
- Enable secure password recovery
- Prevent unauthorized password changes
Token Hashing Strategy¶
All stateful tokens (refresh, email verification, password reset) are hashed before storage:
# Generation
plain_token = secrets.token_urlsafe(32) # 256 bits of entropy
token_hash = bcrypt.hashpw(plain_token, bcrypt.gensalt(rounds=12))
# Storage
db.store(token_hash) # Never store plain token!
# Return to client
return plain_token # Client needs plain token
Why Hash Tokens?¶
Scenario: Database compromise
| Token Type | Stored As | If DB Leaked |
|---|---|---|
| Plain text | a8f4e2d9... |
❌ Attacker can login as anyone |
| Hashed | $2b$12$... |
✅ Attacker can't use tokens |
Cost: ~300ms bcrypt verification (acceptable for refresh flow)
Validation Flow¶
# Client sends plain token
incoming_token = "a8f4e2d9c1b7..."
# Server validates
all_tokens = db.query(RefreshToken).filter(revoked_at=None)
for token_record in all_tokens:
if bcrypt.verify(incoming_token, token_record.token_hash):
# Valid token found!
return generate_new_access_token()
# No match found
raise AuthenticationError("Invalid token")
Implementation Details¶
Authentication Flows¶
Flow 1: Registration & Email Verification¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
participant E as Email Service
C->>S: POST /auth/register<br/>{email, password, name}
S->>S: Hash password (bcrypt)
S->>DB: Create user (inactive)
S->>S: Generate verification token
S->>S: Hash token (bcrypt)
S->>DB: Store token hash
S->>E: Send verification email
S-->>C: {message: "Check email"}
Note over C,E: User clicks link in email
C->>S: GET /auth/verify-email?token=abc123
S->>S: Hash incoming token
S->>DB: Find matching hash
S->>DB: Mark user as verified
S->>DB: Mark token as used
S-->>C: {message: "Email verified"}
Flow 2: Login¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: POST /auth/login<br/>{email, password}
S->>DB: Fetch user by email
S->>S: Verify password (bcrypt)
S->>S: Check email verified
S->>S: Check account active
S->>S: Generate JWT access token
S->>S: Generate opaque refresh token
S->>S: Hash refresh token
S->>DB: Store refresh token hash
S-->>C: {access_token: "eyJhbGci...",<br/>refresh_token: "a8f4e2d9...",<br/>user: {...}}
C->>C: Store tokens securely
Flow 3: Authenticated API Request¶
sequenceDiagram
participant C as Client
participant S as Server
C->>S: GET /api/v1/providers<br/>Authorization: Bearer eyJhbGci...
S->>S: Extract JWT from header
S->>S: Verify JWT signature
S->>S: Check expiration
S->>S: Extract user_id from JWT
Note over S: No DB lookup needed!
S-->>C: {providers: [...]}
Flow 4: Token Refresh¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
Note over C: Access token expired after 30 min
C->>S: POST /auth/refresh<br/>{refresh_token: "a8f4e2d9..."}
S->>S: Hash incoming token
S->>DB: Find matching hash
S->>DB: Verify not expired
S->>DB: Verify not revoked
S->>S: Generate new JWT access token
S-->>C: {access_token: "eyJnEw...",<br/>refresh_token: "a8f4e2d9..." (SAME)}
Flow 5: Logout¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: POST /auth/logout<br/>{refresh_token: "a8f4e2d9..."}
S->>S: Hash incoming token
S->>DB: Find matching hash
S->>DB: Mark token as revoked
S->>DB: Set revoked_at timestamp
S-->>C: {message: "Logged out successfully"}
C->>C: Delete tokens from client storage
⚠️ Important: Logout Behavior & Token Revocation:
When a user logs out, only the refresh token is revoked. The JWT access token remains valid until its natural expiration (30 minutes). This is by design and consistent with industry-standard JWT implementations.
What Gets Invalidated:
| Token Type | Revoked on Logout? | Why? |
|---|---|---|
| Refresh Token (Opaque) | ✅ Yes (Immediate) | Stored in database, can be marked as revoked |
| Access Token (JWT) | ❌ No (Expires naturally) | Stateless, no database tracking |
Why Access Tokens Can't Be Immediately Revoked:
- Stateless by Design: JWTs are validated by signature only, no database lookup
- Performance: Checking a revocation list defeats JWT's scalability benefit
- Industry Standard: Auth0, GitHub, Google, AWS Cognito all work this way
Security Implications:
┌──────────────────────────────────────────────────────────────┐
│ After Logout: What Can/Cannot Be Done │
└──────────────────────────────────────────────────────────────┘
✅ CAN (with old access token, for ~30 min):
- Access protected API endpoints
- Read user profile
- Perform authenticated actions
❌ CANNOT (refresh token revoked):
- Get new access tokens
- Extend session beyond current token expiration
- Refresh authentication after 30 minutes
Why This Is Acceptable:
- Short Window: 30 minutes is industry-standard (configurable)
- No Session Extension: Can't get new tokens without refresh token
- Long-term Protection: Refresh token (30 days) is properly revoked
- Performance vs Security: Acceptable trade-off for stateless scalability
Testing Logout:
# 1. Logout revokes refresh token
curl -k -X POST "$BASE_URL/api/v1/auth/logout" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"refresh_token": "'$REFRESH_TOKEN'"}'
# → {"message": "Logged out successfully"}
# 2. Verify refresh token is revoked
curl -k -X POST "$BASE_URL/api/v1/auth/refresh" \
-d '{"refresh_token": "'$REFRESH_TOKEN'"}'
# → 401 Unauthorized: "Invalid or revoked refresh token" ✅
# 3. Access token STILL WORKS (until expiry)
curl -k -X GET "$BASE_URL/api/v1/auth/me" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# → 200 OK: Returns user profile ⚠️ Expected behavior
If Immediate Revocation Is Required:
For use cases requiring immediate JWT revocation (rare):
# Option 1: JWT Blocklist (adds database lookup)
# - Store revoked JTI (JWT ID) in Redis
# - Check every JWT against blocklist
# - Sacrifices stateless benefit
# Option 2: Shorter Access Token TTL
# - Reduce from 30 min to 5-10 min
# - More frequent refresh operations
# - Better security, more API calls
# Option 3: User-level revocation flag
# - Add database lookup for critical endpoints
# - Check user.is_active on sensitive operations
# - Hybrid approach: stateless + selective checks
Current Implementation: Pattern A (Recommended):
✅ Refresh tokens: Immediately revocable (opaque, database-backed)
⚠️ Access tokens: Valid until expiration (JWT, stateless)
📚 Industry standard: 30-minute window is acceptable for most applications
Flow 6: Password Reset with Session Revocation¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
participant E as Email Service
C->>S: POST /password-resets/<br/>{email: "user@example.com"}
S->>DB: Find user by email
S->>S: Generate reset token
S->>S: Hash token (bcrypt)
S->>DB: Store token hash
S->>E: Send reset email
S-->>C: {message: "If account exists, email sent"}
Note over C,E: User clicks link in email
C->>S: PATCH /password-resets/{token}<br/>{new_password: "NewSecurePass123!"}
S->>DB: Verify token
S->>DB: Check token not expired
S->>S: Validate new password strength
S->>S: Hash new password
S->>DB: Update password hash
S->>DB: Mark reset token as used
S->>DB: REVOKE ALL refresh tokens 🔒
S->>E: Send confirmation email
S-->>C: {message: "Password reset successfully"}
Note over C: User must login again with new password
🔒 Security Enhancement: Session Revocation on Password Reset:
When a user resets their password, all active refresh tokens are immediately revoked. This ensures that any potentially compromised sessions are terminated.
Why This Is Critical:
┌──────────────────────────────────────────────────────────────┐
│ Scenario: Password Reset After Compromise │
└──────────────────────────────────────────────────────────────┘
1. Attacker compromises user's password
2. Attacker logs in (gets refresh token, 30-day lifetime)
3. User realizes account compromise
4. User requests password reset
❌ WITHOUT session revocation:
→ Attacker's refresh token still works for 30 days
→ Attacker can continue accessing account
→ Password change provides NO security!
✅ WITH session revocation:
→ All refresh tokens revoked (including attacker's)
→ Attacker cannot get new access tokens
→ Account secured immediately
→ User must re-login on all devices
Implementation Details:
async def reset_password(self, token: str, new_password: str) -> User:
"""Reset password and revoke all sessions."""
# ... validate token and password ...
# Update password
user.password_hash = self.password_service.hash_password(new_password)
# Mark reset token as used
reset_token.used_at = datetime.now(timezone.utc)
# 🔒 SECURITY: Revoke ALL existing refresh tokens
result = await self.session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user.id,
RefreshToken.is_revoked == False
)
)
active_tokens = result.scalars().all()
for token_record in active_tokens:
token_record.revoked_at = datetime.now(timezone.utc)
token_record.is_revoked = True
logger.info(f"Password reset: Revoked {len(active_tokens)} sessions")
await self.session.commit()
return user
Testing Password Reset Security:
# 1. Login and save tokens
curl -k -X POST "$BASE_URL/api/v1/auth/login" \
-d '{"email": "user@example.com", "password": "OldPass123!"}'
# Save: OLD_REFRESH_TOKEN, OLD_ACCESS_TOKEN
# 2. Request password reset
curl -k -X POST "$BASE_URL/api/v1/password-resets/" \
-d '{"email": "user@example.com"}'
# 3. Extract reset token from logs (development mode)
docker logs dashtam-dev-app | grep 'reset-password?token='
# 4. Complete password reset
curl -k -X PATCH "$BASE_URL/api/v1/password-resets/$RESET_TOKEN" \
-d '{"new_password": "NewSecurePass123!"}'
# 5. Verify old refresh token is revoked
curl -k -X POST "$BASE_URL/api/v1/auth/refresh" \
-d '{"refresh_token": "'$OLD_REFRESH_TOKEN'"}'
# → 401 Unauthorized ✅ (Token revoked by password reset)
# 6. Verify old access token still works (until expiry)
curl -k -X GET "$BASE_URL/api/v1/auth/me" \
-H "Authorization: Bearer $OLD_ACCESS_TOKEN"
# → 200 OK ⚠️ (Access token valid for ~30 min, then expires)
User Experience:
After password reset:
- ✅ All devices logged out (refresh tokens revoked)
- ⚠️ Current sessions may work for ~30 min (access tokens valid until expiry)
- ✅ Cannot extend sessions (refresh blocked)
- ✅ Must re-login with new password on all devices
Security vs Convenience:
| Approach | Security | User Impact |
|---|---|---|
| Revoke refresh only (current) | ✅ High | ⚠️ Logged out all devices |
| Revoke nothing | ❌ Low | ✅ No disruption |
| Revoke access + refresh | ✅✅ Highest | ❌❌ Complex JWT blocklist |
Industry Standard: Password reset → revoke all refresh tokens (Google, GitHub, Auth0)
Database Schema¶
users Table¶
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Authentication
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
-- Email verification
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
-- Account security
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
account_locked_until TIMESTAMPTZ,
-- Login tracking
last_login_at TIMESTAMPTZ,
last_login_ip INET
);
CREATE INDEX idx_users_email ON users(email);
refresh_tokens Table¶
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Token data
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL, -- bcrypt hash
expires_at TIMESTAMPTZ NOT NULL,
-- Revocation
revoked_at TIMESTAMPTZ,
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
-- Device tracking
device_info TEXT,
ip_address INET,
user_agent TEXT,
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_is_revoked ON refresh_tokens(is_revoked);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
email_verification_tokens Table¶
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE INDEX idx_email_verification_tokens_user_id ON email_verification_tokens(user_id);
CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);
password_reset_tokens Table¶
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
ip_address INET,
user_agent TEXT
);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at);
API Endpoints¶
Authentication Endpoints¶
| Endpoint | Method | Purpose | Auth Required |
|---|---|---|---|
/api/v1/auth/register |
POST | Register new user | No |
/api/v1/auth/verify-email |
POST | Verify email address | No |
/api/v1/auth/login |
POST | Login with credentials | No |
/api/v1/auth/refresh |
POST | Get new access token | No* |
/api/v1/auth/logout |
POST | Revoke refresh token | Yes |
/api/v1/auth/password-reset/request |
POST | Request password reset | No |
/api/v1/auth/password-reset/confirm |
POST | Confirm password reset | No |
/api/v1/auth/me |
GET | Get current user profile | Yes |
/api/v1/auth/me |
PATCH | Update user profile | Yes |
*Requires valid refresh token, not access token
Example Requests¶
Register¶
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"name": "John Doe"
}
→ 201 Created
{
"message": "Registration successful. Please check your email to verify your account."
}
Login¶
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}
→ 200 OK
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "a8f4e2d9c1b7f6e3d2c8b4a1e9f7d6c5...",
"token_type": "bearer",
"expires_in": 1800,
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com",
"name": "John Doe",
"email_verified": true,
"is_active": true,
"created_at": "2025-10-04T20:00:00Z",
"last_login_at": "2025-10-04T22:00:00Z"
}
}
Protected Endpoint¶
GET /api/v1/providers
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
→ 200 OK
{
"providers": [...]
}
Refresh Token¶
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "a8f4e2d9c1b7f6e3d2c8b4a1e9f7d6c5..."
}
→ 200 OK
{
"access_token": "eyJnEwMiOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "a8f4e2d9c1b7f6e3d2c8b4a1e9f7d6c5...",
"token_type": "bearer",
"expires_in": 1800
}
Services Architecture¶
src/services/
├── auth_service.py # Orchestrates auth flows
├── password_service.py # Password hashing & validation
├── jwt_service.py # JWT generation & validation
└── email_service.py # Email sending (verification, reset, etc.)
AuthService¶
Responsibility: Orchestrate all authentication workflows
class AuthService:
"""Main authentication service (async)."""
async def register_user(email, password, name) -> User
async def verify_email(token) -> User
async def login(email, password) -> Tuple[str, str, User]
async def refresh_access_token(refresh_token) -> str
async def logout(refresh_token) -> None
async def request_password_reset(email) -> None
async def reset_password(token, new_password) -> User
async def update_user_profile(user_id, name) -> User
async def get_user_by_id(user_id) -> User
# Private helpers
async def _create_refresh_token(user_id) -> Tuple[str, RefreshToken]
async def _create_verification_token(user_id) -> Tuple[str, EmailVerificationToken]
async def _create_password_reset_token(user_id) -> Tuple[str, PasswordResetToken]
PasswordService¶
Responsibility: Password hashing and validation (sync)
class PasswordService:
"""Handles password hashing with bcrypt."""
def hash_password(plain_password: str) -> str
def verify_password(plain_password: str, hashed: str) -> bool
def validate_password_strength(password: str) -> Tuple[bool, str]
def needs_rehash(hashed: str) -> bool
JWTService¶
Responsibility: JWT generation and validation (sync)
class JWTService:
"""Handles JWT operations."""
def create_access_token(user_id, email, additional_claims=None) -> str
def decode_token(token) -> Dict[str, Any]
def verify_token_type(token, expected_type) -> Dict[str, Any]
def get_user_id_from_token(token) -> UUID
def get_token_jti(token) -> UUID # Deprecated for opaque tokens
Security Considerations¶
Token Revocation & Logout Behavior¶
⚠️ IMPORTANT: When users logout, only the refresh token is immediately revoked. The JWT access token remains valid until its natural expiration (30 minutes). This is by design for stateless JWT implementations.
Key Points:
- ✅ Refresh token: Immediately revoked (can't get new access tokens)
- ⚠️ Access token: Valid until expiry (can still access API for ~30 min)
- 📚 See Flow 5: Logout for detailed explanation and testing examples
This is the industry-standard trade-off between performance and immediate revocation. For use cases requiring immediate JWT revocation, see alternative approaches in the logout flow documentation.
Security Features:
✅ Token Hashing - Database compromise protection
✅ Short Access Token TTL - Limits exposure (30 min)
✅ Refresh Token Revocation - Logout capability
✅ Token Expiration - Automatic cleanup
✅ Device Tracking - Monitor sessions
✅ IP Logging - Detect suspicious activity
✅ Email Verification Required - Prevent fake accounts
✅ Password Complexity Rules - Enforce strong passwords
✅ Account Lockout - Brute force protection
Token Storage (Client-Side)¶
| Storage Method | Access Token | Refresh Token |
|---|---|---|
| Memory (React state) | ✅ Recommended | ❌ Lost on reload |
| httpOnly Cookie | ⚠️ CSRF risk | ✅ Recommended |
| localStorage | ❌ XSS vulnerability | ❌ XSS vulnerability |
| sessionStorage | ⚠️ Acceptable | ❌ Lost on tab close |
Recommendation:
- Access Token: React state/memory
- Refresh Token: httpOnly cookie (auto-sent)
Password Requirements¶
MIN_LENGTH = 8
REQUIRE_UPPERCASE = True # At least 1: A-Z
REQUIRE_LOWERCASE = True # At least 1: a-z
REQUIRE_DIGIT = True # At least 1: 0-9
REQUIRE_SPECIAL = True # At least 1: !@#$%^&*
Account Lockout¶
# After 10 failed login attempts
LOCKOUT_DURATION = 1 hour
MAX_FAILED_ATTEMPTS = 10
# Reset counter on successful login
Token Expiration¶
| Token Type | Default TTL | Configurable |
|---|---|---|
| Access Token | 30 minutes | ACCESS_TOKEN_EXPIRE_MINUTES |
| Refresh Token | 30 days | REFRESH_TOKEN_EXPIRE_DAYS |
| Email Verification | 24 hours | EMAIL_VERIFICATION_TOKEN_EXPIRE_HOURS |
| Password Reset | 1 hour | PASSWORD_RESET_TOKEN_EXPIRE_HOURS |
Rate Limiting (Future)¶
# Planned rate limits
LOGIN_ATTEMPTS_PER_IP = 5 per 15 minutes
PASSWORD_RESET_REQUESTS = 3 per hour per email
EMAIL_VERIFICATION_RESEND = 3 per hour per email
REFRESH_TOKEN_USAGE = 10 per hour per token
Performance Considerations¶
Stateless Access Tokens¶
Benefit: JWT access tokens enable horizontal scalability
- No database lookup for API request authentication
- Signature verification only - Pure cryptographic operation
- Load balancing - Any server can validate any JWT
- Zero network latency - No Redis/DB dependency for auth
Performance Impact:
Traditional Session:
Request → Server → Database lookup → Response
Latency: ~5-20ms per request (DB query)
JWT Access Token:
Request → Server → Signature verify → Response
Latency: ~0.1-0.5ms (CPU only)
Speed improvement: 10-200x faster
Refresh Token Database Lookups¶
Cost: Refresh token validation requires database lookup
- Frequency: Once per 30 minutes (vs every API call)
- Operation: Hash comparison (bcrypt.verify) + DB query
- Latency: ~300-500ms (bcrypt + DB round-trip)
Why This Is Acceptable:
- Infrequent: 1 DB hit per 30 min vs 1000s of API calls
- User experience: Refresh happens in background
- Security benefit: Enables token revocation
- Scalability: Still horizontal (DB handles refresh load easily)
Example Load:
10,000 active users
- Access token validation: 10,000,000 requests/day (no DB)
- Refresh token validation: 20,000 requests/day (DB)
Database load: 0.2% of requests
Performance: 99.8% stateless
Bcrypt Performance¶
Cost: ~300ms per bcrypt operation (rounds=12)
Operations Using Bcrypt:
- Password hashing (registration): 1x per user signup
- Password verification (login): 1x per login
- Token hashing (refresh token): 1x per login
- Token verification (refresh): 1x per 30 minutes
Optimization Strategies:
- Accept the cost - Security > performance for auth operations
- Async execution - FastAPI handles bcrypt in threadpool
- Cache nothing - Security tokens must be verified every time
- Monitor load - Use APM to track auth endpoint latency
Why 12 Rounds?
- Security: OWASP recommended minimum
- Performance: ~300ms is acceptable for auth endpoints
- Future-proof: As CPUs get faster, 12 rounds remains secure
Testing Strategy¶
Test Pyramid¶
Test Distribution:
| Test Level | Coverage | Focus |
|---|---|---|
| E2E Tests | 10% | Full authentication flows end-to-end |
| Integration Tests | 20% | API endpoint testing, database operations |
| Unit Tests | 70% | Service methods, token validation, business logic |
Rationale: The pyramid structure emphasizes a solid foundation of fast unit tests, with fewer but more comprehensive integration and E2E tests at higher levels.
Test Coverage Requirements¶
| Component | Target Coverage |
|---|---|
| AuthService | 95%+ |
| PasswordService | 95%+ |
| JWTService | 90%+ |
| API Endpoints | 85%+ |
| Overall | 85%+ |
Key Test Scenarios¶
✅ Registration
- Valid registration
- Duplicate email
- Weak password
- Email sending failure
✅ Email Verification
- Valid token
- Expired token
- Already used token
- Invalid token
✅ Login
- Valid credentials
- Invalid password
- Unverified email
- Inactive account
- Locked account
✅ Token Refresh
- Valid refresh token
- Expired refresh token
- Revoked refresh token
- Invalid refresh token
✅ Logout
- Valid logout
- Already revoked token
- Invalid token
✅ Password Reset
- Request reset
- Valid reset token
- Expired reset token
- Weak new password
Future Enhancements¶
🔲 Token Rotation - Rotate refresh token on every use
🔲 Multi-Factor Authentication - TOTP/SMS verification
🔲 Social Login - OAuth with Google, GitHub
🔲 Device Management - View/revoke all sessions
🔲 Rate Limiting - Prevent brute force attacks
🔲 Security Events - Email notifications for suspicious activity
🔲 Refresh Token Families - Detect token reuse attacks
🔲 IP Whitelisting - Restrict login by IP (optional)
References¶
- RFC 6749: OAuth 2.0
- RFC 7519: JSON Web Token (JWT)
- OWASP Authentication Cheat Sheet
- Auth0: Token Best Practices
- JWT.io
Related Dashtam Documents:
- Registration Flow - Registration flow testing
- Login Flow - Login flow testing
- Complete Auth Flow - End-to-end testing
- JWT Auth Quick Reference - Quick reference
- Async vs Sync Patterns - Service implementation patterns
- RESTful API Design - API endpoint design standards
- Schemas Design - Request/response schema patterns
Document Information¶
Template: architecture-template.md Created: 2025-10-04 Last Updated: 2025-10-16