Authentication and Session Management¶
Overview¶
The auth flow covers the complete lifecycle of a user session in OneWallet — from the initial email OTP verification that starts registration through to JWT-based access on every subsequent request, token refresh, and logout. Auth Center (Serverpod) is the single authority for all authentication. It issues JWTs, manages session state in Redis, and exposes a /auth/validate endpoint that nginx uses as an auth_request gateway to protect Payment Manager and other backend services. Biometric login is available as a convenience path after the first password-based session is established.
Participants¶
| Service | Role in This Flow |
|---|---|
| Flutter App | Initiates login/registration; stores tokens in flutter_secure_storage; presents credentials |
| Auth Center (Serverpod) | Issues tokens; validates sessions; manages user lifecycle; exposes /auth/validate |
| PostgreSQL | Stores users, roles, device tokens (public.device_token), user profiles |
| Redis | Stores OTPs (TTL 10 min); session revocation state; account lockout counters |
| nginx (API Gateway) | Performs auth_request to /auth/validate before proxying requests to PM and other services |
| Payment Manager | Receives proxied requests with X-User-Id / X-User-Roles headers injected by nginx |
Diagram¶

Prerequisites¶
For new registration:
- User has a valid email address not already registered in public.users
- SMTP provider is configured in Auth Center
For login (returning user):
- User has status other than registration_in_progress or registration_expired
- User's account is not locked (fewer than 5 failed login attempts in the last 15 minutes)
Registration Flow (New User — Email OTP)¶
Step R1: User Enters Email¶
Service: Flutter App → Auth Center
Action: User types email address. App calls POST /auth/register/email/start.
Rate limits enforced by nginx: max 5 requests/min per IP. Auth Center enforces: max 3 OTP requests per email per 10 minutes (Redis counter otp_rate:<email>).
Duplicate check: if email already exists in public.users with status != 'registration_in_progress' → error "Email already registered".
Step R2: OTP Generated and Sent¶
Service: Auth Center
Action: Generates a 6-digit OTP. Stores in Redis: SET otp:<email> <code> EX 600 (TTL 10 minutes). Sends via SMTP.
Data: OTP stored in Redis; email sent to user.
Step R3: User Submits OTP¶
Service: Flutter App → Auth Center
Action: User enters the 6-digit code. App calls POST /auth/register/email/verify with { email, otp }.
Auth Center validates:
- OTP matches Redis value for key otp:<email>
- Max 5 attempts enforced (Redis counter otp_attempts:<email>)
- OTP not expired (Redis TTL check)
Step R4: Temporary User Created and registration_token Issued¶
Service: Auth Center
Action: On OTP success:
1. Creates a user record:
INSERT INTO users (email, status, registration_expires_at, is_archived, created_at)
VALUES (<email>, 'registration_in_progress', NOW() + INTERVAL '7 days', false, NOW())
registration_token (JWT):
3. Deletes OTP from Redis.
Data: registration_token returned to Flutter. Stored in flutter_secure_storage.
Step R5: KYC Flow¶
Service: Flutter App → Auth Center
Action: All subsequent registration steps (photo upload, KYC processing, data review, T&C acceptance) require registration_token in the Authorization: Bearer header. See kyc-verification.md for full details.
The registration_token is distinct from the access_token — it cannot be used to access payment features.
Step R6: Registration Finalized (KYC Approved)¶
Service: Auth Center
Action: After operator KYC approval, Auth Center:
1. Updates users.status to the post-KYC state (accessible app with payments pending full verification)
2. Creates a full session: issues access_token (JWT, TTL 15 min) + refresh_token (JWT, TTL 30 days)
3. Stores session in Redis for revocation tracking
Data: Both tokens returned to Flutter. Stored in flutter_secure_storage.
Login Flow (Returning User — Email + Password)¶
Step L1: User Enters Credentials¶
Service: Flutter App → Auth Center
Action: User enters email and password. App calls POST /auth/login with { email, password }.
Rate limits: max 10 requests/min per IP (nginx). Account lockout: after 5 failed attempts, Redis counter lockout:<email> blocks login for 15 minutes.
Step L2: Auth Center Validates Credentials¶
Service: Auth Center
Action:
1. Queries public.users by email
2. Checks is_archived = false and account not locked (Redis lockout:<email>)
3. Verifies password: bcrypt.compare(inputPassword, users.password_hash) (cost factor >= 12)
4. Loads user roles from public.user_roles JOIN public.roles
On success: resets lockout counter.
On failure: increments lockout:<email> counter; if >= 5 → sets TTL 15 min on counter.
Step L3: Session Created — Tokens Issued¶
Service: Auth Center
Action: Creates JWT pair:
access_token: ES256, TTL 15 min, payload: { userId, roles[], kycStatus, type: "access" }
refresh_token: ES256, TTL 30 days, payload: { userId, sessionId, type: "refresh" }
Session stored in Redis: SET session:<sessionId> <userId> EX 2592000 (30 days). This enables instant revocation.
Data: Both tokens returned to Flutter. Stored in flutter_secure_storage.
Step L4: Biometric Enrollment (Optional, Post-Login)¶
Service: Flutter App → Auth Center
Action: After successful login, app offers biometric enrollment. If user accepts, calls POST /auth/biometric/enroll with Authorization: Bearer <access_token>.
Auth Center creates a biometric credential linked to the device. TTL: 30–60 days (configurable via security policy).
Data: Biometric key stored in public.device_token table; device-side key stored in Keychain/Keystore.
Biometric Login Flow¶
Step B1: Biometric Authentication¶
Service: Flutter App → Auth Center
Action: User authenticates via device biometrics (fingerprint/Face ID). App retrieves the stored device credential and calls POST /auth/biometric/login.
Auth Center validates:
- Biometric credential is active and not expired
- Associated sessionId is still valid in Redis
On success: issues new access_token (15 min) and optionally refreshes the biometric credential TTL.
Token Usage on Every Request¶
Step U1: Flutter Attaches Access Token¶
Service: Flutter App
Action: Every API call to Serverpod or the PM proxy includes Authorization: Bearer <access_token>.
Step U2: nginx auth_request Validation¶
Service: nginx → Auth Center
Action: nginx intercepts the request. For protected routes (everything except /auth/*, /webhooks/*, /health), nginx performs a subrequest:
Auth Center validation logic:
1. Verifies JWT signature (ES256)
2. Checks token type = "access" and not expired
3. Checks Redis session:<sessionId> still exists (not revoked)
4. Loads roles from PostgreSQL
Success response (HTTP 200):
nginx injects X-User-Id: 42 and X-User-Roles: user headers and proxies the original request.
Failure (HTTP 401): nginx returns 401 to client; original request is not forwarded.
Step U3: Downstream Service Trusts Headers¶
Service: Payment Manager (or other backend service)
Action: PM receives the request with X-User-Id already validated by nginx. Does not re-validate the JWT. Uses X-User-Id as the authoritative user identity for all DB operations.
Token Refresh¶
Step TR1: Access Token Nearing Expiry¶
Service: Flutter App
Action: Before the access token expires (or after receiving a 401), Flutter calls POST /auth/refresh with { refreshToken }.
Step TR2: Auth Center Rotates Tokens¶
Service: Auth Center
Action:
1. Validates the refresh_token (signature, expiry, type = "refresh")
2. Checks Redis session:<sessionId> still exists
3. Invalidates old refresh_token (one-time use: deletes old session entry in Redis)
4. Issues new access_token (15 min) and new refresh_token (30 days)
5. Creates new session entry in Redis
Data: New token pair returned to Flutter; old refresh token is invalidated.
Logout¶
Step LO1: User Logs Out¶
Service: Flutter App → Auth Center
Action: App calls POST /auth/logout with Authorization: Bearer <access_token>.
Auth Center:
1. Extracts sessionId from the token
2. Deletes session:<sessionId> from Redis — this instantly revokes all further use of both access_token and refresh_token for this session
3. Returns HTTP 200
Data: Session entry deleted from Redis; Flutter clears tokens from flutter_secure_storage.
Device Token Registration¶
Step DT1: Register for Push Notifications¶
Service: Flutter App → Auth Center
Action: After successful login (or on app resume), Flutter registers the FCM/APNs device token by calling the device token endpoint.
Data:
INSERT INTO public.device_token (userId, token, platform, createdAt)
VALUES (<userId>, '<fcmToken>', 'android', NOW())
ON CONFLICT (userId, platform) DO UPDATE SET token=<fcmToken>, createdAt=NOW();
JWT Lifecycle Summary¶
ISSUED → Returned on login / registration-complete / biometric-login / refresh
│
├─ access_token: TTL 15 min
│ Used in Authorization header for every request
│ Validated by Auth Center via /auth/validate (called by nginx auth_request)
│
└─ refresh_token: TTL 30 days
Stored in flutter_secure_storage
Used once to obtain new access_token
Rotated (old invalidated, new issued) on each use
Revoked instantly via Redis session delete on logout or password change
REVOKED → DELETE session:<sessionId> from Redis
Triggers: POST /auth/logout, PUT /auth/password (all sessions), device stolen/compromised
EXPIRED → access_token after 15 min (client retries with refresh_token)
refresh_token after 30 days (user must log in again)
biometric_key after 30–60 days (user must re-enroll biometrics)
Registration Token Lifecycle (Cleanup)¶
Temporary users who start but do not complete registration are cleaned up by three scheduled cron jobs:
| Job | Schedule | Action |
|---|---|---|
| Reminder | Daily 09:00 | Email reminder to users in registration_in_progress for 5+ days |
| Archive | Daily 02:00 | UPDATE users SET is_archived=true, status='registration_expired' where created_at < NOW()-7d |
| Hard Delete | Weekly Mon 03:00 | DELETE FROM users WHERE is_archived=true AND archived_at < NOW()-30d; S3 KYC files tagged for deletion |
Error Cases¶
| Scenario | Where Detected | Outcome | Recovery |
|---|---|---|---|
| Invalid OTP | Auth Center (Step R3) | HTTP 401; attempt counter incremented; after 5 attempts: no more checks | User requests new OTP after TTL expires |
| OTP expired (>10 min) | Redis TTL / Auth Center | HTTP 401 "OTP expired" | User requests new OTP |
| OTP rate limit (3 per 10 min) | Auth Center (Step R1) | HTTP 429 | Wait 10 minutes |
| Wrong password | Auth Center (Step L2) | HTTP 401; lockout counter incremented | Re-enter correct password |
| Account locked (5 failed logins) | Auth Center (Step L2) | HTTP 403 with unlock time | Wait 15 minutes |
| Access token expired | nginx auth_request (Step U2) | HTTP 401 | Flutter uses refresh_token to get new access_token |
| Refresh token already used (stolen) | Auth Center (Step TR2) | HTTP 401; session invalidated | User must log in again with password |
| Session revoked (logout on another device) | Redis session check (Step U2) | HTTP 401 | User must log in again |
| Biometric key expired | Auth Center biometric login | HTTP 401 | User re-enrolls biometrics after password login |
| Password changed — all sessions invalidated | Auth Center PUT /auth/password |
All Redis session entries for user deleted | Users on other devices must log in again |
Related Specs¶
/docs/02-auth-module.md— Full auth module documentation (RBAC, token security, cleanup cron jobs, nginxauth_requestconfiguration)/docs/03-kyc-module.md— KYC flow that runs within the registration token context