Lewati ke konten utama

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-otp and POST /auth/verify-otp are 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.users and iam.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
  • auth audit logs are written to the audit schema
  • 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.

ConditionOTP required?Reason
First install + registeryesNo prior session, device unknown
App reopen after token still validnoflutter_secure_storage returns tokens, silent restore
Access token expired (>15 min), refresh token still validno/auth/refresh-token rotates in place
Idle > 30 days (refresh token expired)yesiam.user_sessions.expires_at past NOW() → lookup fails → 401
Uninstall + reinstall on same phoneyesAndroid wipes app data → new device_iduser_devices lookup misses → verification_required (new_device)
Login from a second physical deviceyesSecond device has a different device_id; first device is NOT auto-revoked
Manual logout (Settings > Logout)yesFlutter clears the token from flutter_secure_storage
User clicks "cancel this login" in the security notification email/WAyesBackend calls RevokeDeviceSessions(...new device...) → refresh token invalid for the new device
IP address or User-Agent changes (same refresh token)nohandleRefreshToken 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/slog JSON logs + /metrics

Components

Flutter App

Responsibilities:

  • input the phone number
  • call POST /auth/resolve-phone first
  • choose a verification method only if the flow still needs it
  • call POST /auth/request-otp
  • save request_id for the OTP verification step
  • call POST /auth/verify-otp
  • receive access_token and refresh_token
  • send device_id, device_name, and platform_code for the device-aware flow

Related files:

  • login_page.dart
  • verification_method_page.dart
  • otp_page.dart
  • auth_api_service.dart
  • auth_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.go
  • server.go
  • routes_auth.go
  • whatsapp_webhook.go
  • otp_service.go
  • redis_repository.go
  • postgres_store.go
  • profile/store.go
  • device_store.go
  • device_challenge_store.go
  • merchant/merchant.go
  • email_verification_store.go
  • store_helpers.go
  • token_service.go
  • metrics.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_logs
  • iam.user_sessions
  • iam.user_devices
  • iam.device_login_challenges

Notes:

  • the current implementation uses the final iam, merchant, and audit schemas
  • 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

  1. The user enters a phone number in Flutter.
  2. Flutter normalizes the number and calls POST /auth/resolve-phone.
  3. The backend decides whether the number needs OTP and which channel to use.
  4. Flutter calls POST /auth/request-otp.
  5. The Go backend validates the number, method, and device context.
  6. The backend checks the resend cooldown and rate limit in Redis.
  7. The backend generates a random OTP and a request_id.
  8. The backend SHA-256 hashes the OTP.
  9. The backend stores the OTP challenge in Redis with a TTL.
  10. 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
  1. The backend writes an audit event to PostgreSQL.
  2. The backend returns request_id, expires_in_secs, and retry_after_secs.
  3. Flutter moves to the OTP input page.

OTP Verify Flow

  1. The user enters the OTP in Flutter.
  2. Flutter calls POST /auth/verify-otp with request_id, phone, and code.
  3. The backend fetches the challenge from Redis.
  4. The backend checks the phone match, expiry, and attempts.
  5. The backend hashes the input OTP and compares it with the stored hash.
  6. If wrong, attempts in Redis is incremented.
  7. If correct, the challenge is removed from Redis.
  8. The backend issues a JWT access token and a refresh token.
  9. The backend stores the hash of the refresh token in PostgreSQL.
  10. If this flow is for a new device, the backend marks the device trusted.
  11. The backend sends a security notification + cancel link.
  12. The backend writes a successful-verify audit log.
  13. 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_code
  • device_id
  • device_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-phone already considers device_id, device_name, and platform_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_total
  • kesles_otp_request_success_total
  • kesles_otp_request_failure_total
  • kesles_otp_verify_success_total
  • kesles_otp_verify_failure_total

Environment Variables

Core auth:

  • JWT_SECRET
  • JWT_ISSUER
  • JWT_ACCESS_TOKEN_TTL
  • JWT_REFRESH_TOKEN_TTL

OTP:

  • OTP_EXPIRY
  • OTP_MAX_ATTEMPTS
  • OTP_MAX_REQUESTS_PER_WINDOW
  • OTP_REQUEST_WINDOW
  • OTP_RESEND_COOLDOWN

Redis:

  • REDIS_ADDR
  • REDIS_PASSWORD
  • REDIS_DB

PostgreSQL:

  • POSTGRES_DSN

WhatsApp:

  • WHATSAPP_ACCESS_TOKEN
  • WHATSAPP_APP_SECRET
  • WHATSAPP_PHONE_NUMBER_ID
  • WHATSAPP_VERIFY_TOKEN
  • WHATSAPP_API_VERSION
  • WHATSAPP_TEMPLATE_NAME
  • WHATSAPP_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

  1. Add POST /auth/refresh-token.
  2. Finalize the rotation and revocation policy for the refresh token.
  3. Add a final web page for device-login/cancel.
  4. Enrich auth and OTP metrics.
  5. Add distributed tracing for the auth flow.