Security
Secrets management
- All credentials (Termii API key, Termii webhook secret, JWT signing key, payload encryption key, dev API key) live in
.env, which is gitignored — confirmed there's no path for it to be committed (.env,.env.*are excluded,.env.exampleis explicitly allow-listed). - In production, these are individually-named GitHub Actions environment secrets (not committed anywhere), assembled into
.envon the server at deploy time and never logged — see the README's "Production deployment" section. Production is running real, live Termii credentials, not mock values. Settingsfails to start the app if a required secret is missing for the active mode (see/docs/architecture), so a misconfigured deploy fails at boot, not silently at request time.- Nothing sensitive is ever printed except by the standalone encryption round-trip script (
tests/test_encryption_roundtrip.py), which is explicitly designed to hand a sample key/ciphertext to the Flutter side for cross-language testing — that script is never run against production data.
How sessions/tokens are protected
- Access tokens are short-lived (15 min default) JWTs, HS256-signed with
JWT_SECRET_KEY. They're stateless by design — a compromised access token is only useful for 15 minutes. - Refresh tokens are deliberately not JWTs. They're 48 bytes of
secrets.token_urlsaferandomness, and only a SHA-256 hash of the token is ever stored inrefresh_tokens. A database dump can't be used to mint a session — there's nothing to sign with, and the hashes aren't reversible. - Rotation on every refresh: using a refresh token immediately revokes it and issues a new one. Reusing an already-rotated token fails closed (
401). This limits the blast radius of a stolen refresh token to a single use before the legitimate client's next refresh invalidates it. - Logout is immediate:
POST /auth/logoutsetsrevoked_aton the token's DB row. There's no waiting for a JWT to expire — revocation is a direct DB check on every refresh. - Why not httpOnly cookies? The primary client is the Flutter mobile app, not a browser. Cookies are a browser-session mechanism; a mobile HTTP client (dio) doesn't get automatic cookie-jar behavior the way a browser tab does, and mixing cookie-based web sessions with a token-based mobile client adds complexity for no real benefit here. Bearer tokens, stored in the device's secure storage, are the standard fit for an API-only backend serving a mobile client.
OTP abuse prevention
Enforcement lives in app/services/otp.py, ahead of any call to Termii:
- Resend cooldown: 60 seconds (configurable via
OTP_RESEND_COOLDOWN_SECONDS) between OTP requests for the same phone number, enforced by checking the most recentotp_requestsrow before calling Termii — an abuser can't even burn provider credit by hammering the endpoint. - Hourly cap: 5 requests per phone number per hour (
OTP_MAX_REQUESTS_PER_HOUR), same enforcement point. - Attempt limits: enforced locally against
otp_requests.attempts_usedbefore Termii is consulted, so a maxed-out OTP is markedFAILEDimmediately — in addition to Termii's own server-sidepin_attemptstracking. - Expiry:
pin_time_to_live(default 10 minutes) is re-checked locally before even attempting verification, so an expired code fails fast with a410, not a generic401— in addition to Termii's own server-side expiry. - The plaintext OTP PIN is never stored by us. We only ever hold Termii's opaque
pin_id.
PIN handling (offline-transfer transaction PIN — separate from the OTP login PIN)
- Hashed with PBKDF2-HMAC-SHA256, 100,000 iterations, per-account random salt.
- Verified with
hmac.compare_digest— constant-time, no timing side-channel. - Never logged, never persisted outside the hash, and explicitly redacted from FastAPI's own 422 validation-error responses (a real bug that was found and fixed during the initial build — see
/docs/implementation). - Attempt lockout: 5 wrong attempts locks the account for 30 minutes (
Account.failed_pin_attempts/locked_until,app/services/transfer.py) — even the correct PIN is rejected while locked. There was previously no limit at all on this PIN.
Webhook authenticity
Inbound Termii webhooks are verified via X-Termii-Signature (HMAC-SHA512 of the raw request body, keyed with TERMII_WEBHOOK_SECRET) before the body is parsed at all. A forged webhook can't reach the decryption or balance-mutation code paths.
Known risks and mitigations
| Risk | Mitigation | Status |
|---|---|---|
| PIN travels over SMS (store-and-forward, not a live session) | AES-256-GCM authenticated encryption end-to-end; flagged in the original architecture doc as a reason to consider USSD for production | Accepted for POC, open question for production |
| Stolen refresh token | Hashed at rest, rotated on every use, immediately revocable | Mitigated |
| Forged webhook triggering a fake debit | HMAC-SHA512 signature check before any parsing | Mitigated |
| OTP brute force | Cooldown + hourly cap + attempt limit, enforced locally (backed by Termii's own server-side limit too) | Mitigated |
| Offline transfer PIN brute force | 5-attempt lockout, 30 minutes (previously no limit at all) | Mitigated |
| Sender ID spoofing / phishing via SMS | Out of scope for this POC — no different from any SMS-based OTP system's baseline exposure | Not addressed |
| Duplicate/replayed webhook causing a double debit | Idempotency check (unique provider_message_id constraint) happens before any balance mutation, proven under retry in tests |
Mitigated |
Key rotation for PAYLOAD_ENCRYPTION_KEY / JWT_SECRET_KEY |
Not implemented — out of scope for POC, flagged as a required follow-up before production | Not addressed |
PAYLOAD_ENCRYPTION_KEY is a single static key shipped inside every app install |
Inherent to an app-embedded symmetric key with no connectivity for a handshake; anyone extracting it from the APK can craft valid payloads | Not addressed — architectural tradeoff, flagged for production review |