WhatsApp OTP Auth Architecture
This document describes the implementation state of the WhatsApp-based OTP path active in Kesles Merchant.
Current Implementation Status
Status updated 2026-05-26 (post DB cutover to db_kesles_merchant_notification). Earlier baseline snapshot: 8 April 2026.
- mobile login starts at
POST /auth/resolve-phone POST /auth/request-otpandPOST /auth/verify-otpare the active main OTP path- OTP can still be sent via the WhatsApp Cloud API
- for a new device, the OTP channel is selected based on the available verified identity:
- verified Gmail first
- WhatsApp if no verified Gmail
- after a successful OTP verification, the backend now:
- creates or ensures
iam.usersandiam.auth_identities - marks the device
trusted - creates a new session in
iam.user_sessions - creates a cancel token for the new device login
- sends a security notification
- creates or ensures
- auth audit logs are written to the
auditschema - the app's tokens and session state use
flutter_secure_storage
This document focuses on the WhatsApp OTP path, but it should be read together with Device Verification Login Architecture for the device-aware behavior that comes with it.
For PII retention policy on iam.otp_challenges, iam.otp_request_locks, and related auth runtime tables (UU PDP No. 27/2022 compliance), see OTP & Auth PII Retention Plan (merchant_docs/docs/plans/otp-pii-retention-plan.md). Background goroutine startAuthPIICleanupWorker runs hourly to delete expired rows via SQL function iam.cleanup_expired_auth_pii().
When OTP Is Required
Audit reference: this table verified against backend code on 2026-05-17. Conditions are explicit, not assumed.
| Condition | OTP required? | Reason |
|---|---|---|
| First install + register | yes | No prior session, device unknown |
| App reopen after token still valid | no | flutter_secure_storage returns tokens, silent restore |
| Access token expired (>15 min), refresh token still valid | no | /auth/refresh-token rotates in place |
| Idle > 30 days (refresh token expired) | yes | iam.user_sessions.expires_at past NOW() → lookup fails → 401 |
| Uninstall + reinstall on same phone | yes | Android wipes app data → new device_id → user_devices lookup misses → verification_required (new_device) |
| Login from a second physical device | yes | Second device has a different device_id; first device is NOT auto-revoked |
| Manual logout (Settings > Logout) | yes | Flutter clears the token from flutter_secure_storage |
| User clicks "cancel this login" in the security notification email/WA | yes | Backend calls RevokeDeviceSessions(...new device...) → refresh token invalid for the new device |
| IP address or User-Agent changes (same refresh token) | no | handleRefreshToken does NOT validate IP/UA — see "Known Gaps" below |
Summary: OTP is required when (a) device identity changes, (b) refresh token is invalid/expired/revoked, or (c) the local token is gone. Network and IP changes alone do not trigger step-up.
Known Gaps
These are intentional design trade-offs documented for future security review, not bugs. Hardening roadmap for both gaps is captured in Auth Session Hardening Plan (di working notes — merchant_docs/docs/plans/auth-session-hardening-plan.md).
Gap 1 — Refresh endpoint does not bind to IP / User-Agent
File: auth_refresh_handler.go:58-75
handleRefreshToken looks up the session only by SHA-256 hash of the incoming refresh token, with WHERE revoked_at IS NULL AND expires_at > NOW(). The ip_address and user_agent columns on iam.user_sessions (migration 003) are written once at session creation but never re-validated on subsequent refresh calls.
Implication: an attacker who exfiltrates a refresh token (lost or stolen phone, malware reading flutter_secure_storage on a rooted device, MITM capture during an unencrypted transport hop) can use it from any IP, any network, any user agent — until either the refresh token TTL elapses (default 30 days) or the user manually revokes the session.
Severity: low-to-medium. flutter_secure_storage is backed by Android Keystore / iOS Keychain (hardware-backed on most modern devices), so exfiltration is non-trivial. But the lack of binding means a successful exfiltration grants long-lived access.
Possible future hardening (deferred, requires UX research):
- Soft-bind: require IP/UA match on refresh for high-risk endpoints, prompt re-auth otherwise.
- Hard-bind: invalidate session on IP change. Risk: false positives for users on mobile networks (cell tower handoff changes egress IP), Wi-Fi/4G switching, VPN toggle.
- Token binding via mTLS / DPoP. Heavy lift.
Gap 2 — New-device login does not auto-revoke other devices
Files: otp_service.go:629-687, session_store.go:81-95
When a user verifies OTP from a new device, VerifyOTP calls PersistDeviceSession which is a plain INSERT INTO iam.user_sessions .... It does NOT call RevokePlatformSessions or RevokeDeviceSessions on the user's other devices. The existing session(s) on device A remain valid alongside the new session on device B.
The mitigation in place is a security notification (email if email is verified, otherwise WhatsApp) sent to the user with a cancel link valid for 10 minutes. If the user clicks the cancel link, CancelLoginChallenge revokes the sessions of the NEW device only — never the existing ones.
Implication: by design, a merchant account can be logged in on multiple devices simultaneously (owner + staff phone, owner's tablet, etc.). This is intentional for the merchant workflow but means:
- A compromised secondary device persists until the user notices the security notification and acts within 10 minutes.
- After 10 minutes the cancel link expires; revoking the new device then requires a manual "log out all devices" action — which does not currently exist as a user-facing feature.
Open product question: should iam.user_sessions expose a "session list + revoke per session" UI in the merchant app and/or dashboard? Required for users to recover from a missed-window scenario without contacting support.
Gap 3 — OTP request rate limit is keyed by phone only, not by IP — CRITICAL
Files: auth_mobile_handlers.go:32-85, postgres_otp_repository.go:9-118, redis_repository.go:19-65
Severity: Critical — unlike Gaps 1 & 2 above which are intentional UX trade-offs, this one is a true exploitable hole that warrants prioritized remediation (financial DoS + provider reputation risk).
POST /auth/request-otp enforces rate limit via ReserveRequest(phone, ...) — the row in iam.otp_request_locks (Postgres) or the key auth:otp:cooldown:<phone> / auth:otp:rate:<phone> (Redis) is keyed by phone number only. The HTTP handler never reads r.RemoteAddr or X-Forwarded-For, and RequestOTPInput has no IPAddress field (compare with VerifyOTPInput / ResolvePhoneInput which do).
Implication: a single attacker IP can request OTPs against a large list of phone numbers (e.g., 10,000 random Indonesian mobile numbers) without ever tripping the rate limiter — each phone gets its own lock row. Concrete consequences:
- Financial DoS — Meta WhatsApp template messages are billed per send. An attacker can run up the WA bill arbitrarily.
- WA sender reputation — Meta flags senders with abnormal send patterns; the Kesles WA business number could be throttled or banned.
- Spam to bystanders — phone numbers of non-users (typos, attacker enumeration) receive unwanted OTPs.
- Enumeration assist — combined with the unprotected
/auth/resolve-phone(which returns different responses for registered vs unregistered phones), this becomes a phone-existence oracle.
Mitigation plan: see Auth Session Hardening Plan (di working notes — merchant_docs/docs/plans/auth-session-hardening-plan.md) §"Phase 0 — IP Rate Limit on OTP Request" (prioritized ahead of Active Sessions UI).
Stack
- Frontend: Flutter
- Backend: Go
- Cache: Redis
- Database: PostgreSQL
- WA delivery: Meta WhatsApp Cloud API
- Auth token: JWT access token + refresh token
- Observability:
log/slogJSON logs +/metrics
Components
Flutter App
Responsibilities:
- input the phone number
- call
POST /auth/resolve-phonefirst - choose a verification method only if the flow still needs it
- call
POST /auth/request-otp - save
request_idfor the OTP verification step - call
POST /auth/verify-otp - receive
access_tokenandrefresh_token - send
device_id,device_name, andplatform_codefor the device-aware flow
Related files:
login_page.dartverification_method_page.dartotp_page.dartauth_api_service.dartauth_session_service.dart
Go Backend
Responsibilities:
- validate phone and verification method
- generate OTP and
request_id - hash OTP before persisting
- enforce resend cooldown and rate limit via Redis
- send the authentication template to the WhatsApp Cloud API
- verify OTP by
request_id - issue access token and refresh token
- write the audit log to PostgreSQL
- expose metrics and structured logs
Related files:
main.goserver.goroutes_auth.gowhatsapp_webhook.gootp_service.goredis_repository.gopostgres_store.goprofile/store.godevice_store.godevice_challenge_store.gomerchant/merchant.goemail_verification_store.gostore_helpers.gotoken_service.gometrics.go
Implementation notes:
- the PostgreSQL backend store layer is now split per concern so that auth core, profile, device login, merchant, and email verification no longer pile up in a single large file
Redis
Used for fast, ephemeral, TTL-based data:
- per-phone resend cooldown
- per-phone rate-limit window
- OTP challenge per
request_id - attempts counter
Current key patterns:
auth:otp:cooldown:{phone}auth:otp:rate:{phone}auth:otp:challenge:{request_id}
PostgreSQL
Used for data that needs to last longer:
audit.auth_audit_logsiam.user_sessionsiam.user_devicesiam.device_login_challenges
Notes:
- the current implementation uses the final
iam,merchant, andauditschemas - the table structure has been moved into formal PostgreSQL migrations
Meta WhatsApp Cloud API
Used to:
- send the WhatsApp OTP authentication template active in the environment
- receive status/event webhooks
Webhook Domain
Current status:
- development:
https://kesles.com/merchant/api/webhook/whatsapp - production target:
https://kesles.com/merchant/api/webhook/whatsapp
Notes:
- the active backend routes are:
/merchant/api/webhook/whatsapp- legacy alias
/webhook/whatsapp
- the public domain may be moved via a reverse proxy without changing the backend handler
OTP Request Flow
- The user enters a phone number in Flutter.
- Flutter normalizes the number and calls
POST /auth/resolve-phone. - The backend decides whether the number needs OTP and which channel to use.
- Flutter calls
POST /auth/request-otp. - The Go backend validates the number, method, and device context.
- The backend checks the resend cooldown and rate limit in Redis.
- The backend generates a random OTP and a
request_id. - The backend SHA-256 hashes the OTP.
- The backend stores the OTP challenge in Redis with a TTL.
- The backend sends the OTP via the channel decided by the backend:
- verified Gmail if available for the new device
- WhatsApp if no verified Gmail
- The backend writes an audit event to PostgreSQL.
- The backend returns
request_id,expires_in_secs, andretry_after_secs. - Flutter moves to the OTP input page.
OTP Verify Flow
- The user enters the OTP in Flutter.
- Flutter calls
POST /auth/verify-otpwithrequest_id,phone, andcode. - The backend fetches the challenge from Redis.
- The backend checks the phone match, expiry, and attempts.
- The backend hashes the input OTP and compares it with the stored hash.
- If wrong, attempts in Redis is incremented.
- If correct, the challenge is removed from Redis.
- The backend issues a JWT access token and a refresh token.
- The backend stores the hash of the refresh token in PostgreSQL.
- If this flow is for a new device, the backend marks the device
trusted. - The backend sends a security notification + cancel link.
- The backend writes a successful-verify audit log.
- Flutter receives the tokens for the next session.
Sequence Diagram
sequenceDiagram
participant U as User
participant F as Flutter App
participant B as Backend Go
participant R as Redis
participant P as PostgreSQL
participant W as WhatsApp API
U->>F: Enter WhatsApp number
F->>B: POST /auth/resolve-phone
B-->>F: status + verification_channel
F->>B: POST /auth/request-otp
B->>R: Check cooldown + rate limit
B->>B: Generate OTP + request_id + hash
B->>R: Save challenge + expiry + attempts
B->>W: Send authentication template
B->>P: Insert audit log
B-->>F: request_id, expires_in, retry_after
U->>F: Enter OTP
F->>B: POST /auth/verify-otp
B->>R: Load challenge by request_id
B->>B: Check expiry + compare hash + attempts
B->>P: Insert verify audit log
B->>P: Store hashed refresh token
B-->>F: access_token + refresh_token
API Contract
POST /auth/resolve-phone
Request:
{
"phone": "628114169868",
"platform_code": "mobile_user",
"device_id": "android-abc-123",
"device_name": "Samsung A54"
}
Example response:
{
"status": "verification_required",
"phone": "628114169868",
"reason": "new_device",
"verification_channel": "email"
}
POST /auth/request-otp
Request:
{
"phone": "628114169868",
"method": "whatsapp",
"platform_code": "mobile_user",
"device_id": "android-abc-123",
"device_name": "Samsung A54"
}
Success response:
{
"status": "otp_sent",
"request_id": "otp_xxxxxxxxxxxxxxxxxxxxxxxx",
"phone": "628114169868",
"method": "whatsapp",
"expires_in_secs": 300,
"retry_after_secs": 60
}
Error response:
{
"error": "too many otp requests",
"code": "otp_rate_limited",
"retry_after_secs": 600
}
POST /auth/verify-otp
Request:
{
"request_id": "otp_xxxxxxxxxxxxxxxxxxxxxxxx",
"phone": "628114169868",
"code": "234506"
}
In the active implementation, mobile also sends:
platform_codedevice_iddevice_name
Success response:
{
"status": "verified",
"phone": "628114169868",
"verified": true,
"token_type": "Bearer",
"access_token": "<jwt>",
"refresh_token": "<opaque_token>",
"expires_in_secs": 900
}
Data Handling
OTP
- the raw OTP is not stored
- only the hash of the OTP is stored
- the OTP challenge is removed after a successful verify or once max attempts is reached
- trusted device and cancel challenge are recorded in PostgreSQL for the device-aware flow
Refresh Token
- the refresh token is generated as an opaque random token
- what is stored in the database is its hash, not the raw token
Phone Number
- the backend normalizes to the
62...format - the OTP challenge is constrained to the normalized number
Security Notes
- WhatsApp webhook verification uses
X-Hub-Signature-256 - OTP has a TTL
- OTP has an attempts limit
- OTP requests have a cooldown and rate limit
- the access token is a JWT signed with an internal secret
- the refresh token is stored as a hash in PostgreSQL
Relationship With Device-Aware Login
The current WhatsApp OTP flow remains the main verification path, but its behavior is wired into device-aware login.
Currently active behavior:
resolve-phonealready considersdevice_id,device_name, andplatform_code- a new device cannot directly issue a token
- if the user has no verified Gmail, the new-device OTP is still delivered via WhatsApp
- after a successful device login, the backend already sends a security notification and cancel link
Detailed device-aware flow document:
Device Verification Login Architecture
Observability
Structured Logs
The backend currently uses log/slog with the JSON handler.
Main logs currently captured:
- failed OTP request
- successful OTP request
- failed OTP verify
- successful OTP verify
- WhatsApp webhook received
- failed PostgreSQL initialization
Metrics
Endpoint:
GET /metrics
Counters available today:
kesles_otp_request_totalkesles_otp_request_success_totalkesles_otp_request_failure_totalkesles_otp_verify_success_totalkesles_otp_verify_failure_total
Environment Variables
Core auth:
JWT_SECRETJWT_ISSUERJWT_ACCESS_TOKEN_TTLJWT_REFRESH_TOKEN_TTL
OTP:
OTP_EXPIRYOTP_MAX_ATTEMPTSOTP_MAX_REQUESTS_PER_WINDOWOTP_REQUEST_WINDOWOTP_RESEND_COOLDOWN
Redis:
REDIS_ADDRREDIS_PASSWORDREDIS_DB
PostgreSQL:
POSTGRES_DSN
WhatsApp:
WHATSAPP_ACCESS_TOKENWHATSAPP_APP_SECRETWHATSAPP_PHONE_NUMBER_IDWHATSAPP_VERIFY_TOKENWHATSAPP_API_VERSIONWHATSAPP_TEMPLATE_NAMEWHATSAPP_TEMPLATE_LANGUAGE
Current Implementation Limitations
- the refresh-token endpoint is not yet available
- the refresh token rotation and revocation policy is not yet final
- metrics are simple, no histogram or richer labels yet
- distributed tracing does not exist yet
- the final web page for cancelling a new-device login has not been built; currently still a JSON response
Implementation Backlog
- Add
POST /auth/refresh-token. - Finalize the rotation and revocation policy for the refresh token.
- Add a final web page for
device-login/cancel. - Enrich auth and OTP metrics.
- Add distributed tracing for the auth flow.