Security architecture — stated honestly
This page documents what's actually implemented, including the parts that fall short of the design intent. The Known limitations section at the bottom is not filler — every item there is a real, verified gap in the current code.
JWT architecture
Access tokens are short-lived, refresh tokens rotate on every use, and revocation is enforced through a Redis blacklist checked on every protected request.
AdminUser row exist for this JWT's subject UUID.Token details
| Field | Value |
|---|---|
| Access token TTL | JWT_ACCESS_TOKEN_EXPIRY, default 15m |
| Refresh token TTL | JWT_REFRESH_TOKEN_EXPIRY, default 168h (7 days) |
| Signing | HS256, JWT_SECRET — shared by user and admin tokens (see below) |
| Claims | user_id, user_uuid, email, token_type, verified, kyc_status, 2fa_enabled, session_id + registered claims (exp/iat/nbf, iss:"mynex", sub:UUID, jti) |
| Refresh rotation | Every refresh mints a brand-new jti and immediately revokes the old one — a used-and-discarded refresh token cannot be replayed |
| Revocation store | Redis: blacklist:<raw access token> (access), refresh_token:<userID>:<jti> (refresh) — checked on every request through AuthMiddleware / AdminAuthMiddleware |
| Session binding | Soft — IP/User-Agent/device name are recorded and updated on every refresh for audit purposes, but a refresh from a new IP is not rejected, only logged |
config.JWTConfig.AdminSecret and AdminSessionTimeout are defined
in config but never read anywhere else in the codebase — both admin and user JWTs are
signed and validated with the same JWT_SECRET, and there is no
aud or token-type claim distinguishing "this is an admin token." Admin
authentication is entirely "does an active AdminUser row exist for this
subject UUID" — safe only as long as user and admin UUID namespaces never collide (they're
separate tables with independently generated UUIDs, so collision is astronomically
unlikely, but the isolation is structural coincidence, not a designed second factor).
Transaction PIN
A 6-digit PIN, separate from the account password, required for every money-moving action.
Hashed identically to passwords — same Argon2id function, same parameters — but stored in
Postgres (User.PIN), while the attempt counter and lockout live in Redis.
111111), strictly ascending (123456), strictly descending (654321)pin:lock:<userID> in Redis); attempt counter (pin:attempts:<userID>) has its own 15-minute sliding TTL from the first failure409 DUPLICATE if a PIN is already setconst (
maxAttempts = 3
attemptTTL = 15 * time.Minute
lockDuration = 15 * time.Minute
)
Two-factor authentication
User 2FA (TOTP)
POST /auth/2fa/enable generates a TOTP secret (issuer "MyNEX") and 8
backup codes, but does not flip TwoFactorEnabled until the first
successful verify. Once armed, login issues a 5-minute Redis-backed temp token instead
of real tokens; POST /auth/2fa/verify accepts a backup code (one-shot
consume) or a TOTP code. 2FA is opt-in — a user who never enables it
skips this step at login entirely.
Admin 2FA (TOTP)
Same TOTP library, separate handler. If an admin has 2FA enabled but no secret
provisioned yet, login succeeds anyway with a two_fa_setup_required
flag (soft-fail onboarding path) rather than blocking. Admin passwords are checked with
bcrypt, not Argon2id — two different password hashing schemes coexist
in the codebase, one for each auth path.
RBAC hierarchy
Five roles, strictly ordered — a higher role satisfies any lower gate.
super_admin never appears as an explicit route requirement; it's purely the
top of the hierarchy.
| Min role | Route groups |
|---|---|
| support | /admin/logout, /admin/profile, /admin/dashboard/* |
| finance | /admin/deposits, /admin/withdrawals (+approve), /admin/fx/rates, /admin/wallets/:id/adjust, /admin/adjustments (list), /admin/partners/* |
| compliance | /admin/users (+drill-down), /admin/kyc/*, /admin/cards/* |
| admin | /admin/users/:id/status, /admin/users/:id/force-logout, /admin/reports/daily, /admin/audit-logs, /admin/adjustments/:id/approve|reject, /admin/naqd/* |
| any active admin | /admin/ws — no role gate beyond WSUpgradeMiddleware's blacklist + active check |
REVAMP_BLUEPRINT.md §5 describes admin RBAC roles as "superadmin/ops/compliance/support."
The actual implemented roles are support / finance / compliance / admin / super_admin
— there is no ops role anywhere in the code, and there's a fifth role
(finance) the blueprint doesn't mention. Treat the blueprint's role list as
aspirational; the table above is what's actually enforced.
A second, simpler 3-tier hierarchy (user < moderator < admin,
middleware.RequireRole) exists in the codebase and is wired to exactly one
route group (SetupOracleRoutes) — which is itself never called from anywhere.
It's dead code, not a live security gap, but worth knowing if you go looking for "the" RBAC
middleware and find two.
Maker-checker patterns
NAQD mint/burn
Any mint or burn whose USD-equivalent value is ≥ NAQD_APPROVAL_THRESHOLD_USD
(default 5000) is created as requires_approval before any ledger
posting happens. An admin reject is therefore a pure status transition to
rejected — no compensating reversal needed, unlike a post-execution
chain-step failure.
Wallet adjustments
POST /admin/wallets/:id/adjust (finance role) only creates a
pending row. POST /admin/adjustments/:id/approve (admin role)
hard-enforces a different approver — RequestedBy == adminID
returns 403 FORBIDDEN. Approval, ledger posting, wallet balance update, and
the audit log write all happen inside one DB transaction.
Mobile security
| Control | Status |
|---|---|
| Biometric auth (Face ID / Touch ID / fingerprint) | Implemented — local_auth, wrapped in BiometricService |
| App lock / auto-lock | Implemented — 5 minute default, driven by AppLifecycleState; overlays a privacy curtain when backgrounded so balances don't appear in the OS app-switcher snapshot |
| Secure storage | Implemented — flutter_secure_storage (iOS Keychain / Android EncryptedSharedPreferences). A legacy app-level AES layer with a hardcoded key/IV shipped in every build was found and removed; a one-time migration decrypts old values on upgrade |
| Jailbreak / root detection | Implemented — flutter_jailbreak_detection, blocks app start on a compromised device (fails open if the check itself is unavailable) |
| Certificate pinning | Implemented but opt-in per build — the Dio interceptor is real, but only activates when --dart-define=API_CERT_SHA256=... fingerprints are supplied; empty by default |
| Client-side TOTP | A full local TOTP implementation exists in lib/core/security/mfa_service.dart but is dead code — the real 2FA screen drives everything through the backend /auth/2fa/* endpoints instead |
Key management
| Key | Purpose | Sandbox default |
|---|---|---|
| ENCRYPTION_KEY | AES-256-GCM master key (SHA-256-derived) for card PANs and, via CARD_ENCRYPTION_KEY's fallback chain, shared with card-service | Falls back to a literal dev string if unset — fine for sandbox, must be set in production |
| SOLANA_WALLET_PRIVATE_KEY / POLYGON_WALLET_PRIVATE_KEY | Treasury signing key per chain — genuinely AES-256-GCM encrypted at rest (verified: real decryption code, not a placeholder), key derived from ENCRYPTION_KEY | Unused — sandbox chain needs no key at all |
| CARD_WEBHOOK_SECRET | HMAC-SHA256 for /webhooks/cards/sandbox | Falls back to a dev literal if unset |
| PAYMENTS_SANDBOX_WEBHOOK_SECRET / LOCAL_BANK_WEBHOOK_SECRET | HMAC for payment provider webhooks | Sandbox has a fixed default; local_bank falls back to the gateway API key |
| JWT_SECRET / ADMIN_JWT_SECRET | Token signing | Default placeholder values — server refuses to boot in APP_ENV=production if either is left at its default (checked explicitly in main.go) |
Known limitations
Every item below was confirmed directly in code, not inferred from a comment alone.
- Live-chain dual-write gap. If the process crashes between a live Solana/Polygon chain call succeeding and the DB recording the tx hash, the NAQD worker's recovery sweep resubmits on restart. The sandbox chain closes this gap for itself (idempotent per reference); Solana/Polygon have no equivalent dedup primitive yet. Documented in
blockchain-service/README.md, real for anyone running CHAIN_ENV=live today. - Withdrawals are still simulated.
wallet-service.Withdraw's bank-side settlement is atime.Sleep-backed goroutine, not the durable sweeper pattern top-ups use — explicitly flagged indocs/wiring/payments.mdas a follow-up, and still true. - Partner exposure isn't normalized. Daily/monthly partner-rail limits are tracked per debit-currency independently, not combined — a user sending both MVR and USD in one day has two separate caps, not one.
- Card and partner production integrations are unbuilt.
CardProcessorandPartnerRaileach have exactly one implementation (the sandbox); the interface is designed for a real BIN sponsor / real TnG API but nothing beyond the sandbox has been written. - P2P/QR/bill-pay/NFC don't publish transaction events. Only the top-up settlement engine emits
txn.created/completed/failed— synchronous payment flows complete without a corresponding WebSocket event, a gapdocs/wiring/payments.mdnotes explicitly and which is still open. - Push notifications are wired nowhere.
PushServicehas a real FCM implementation and a mocked APNS one, but no device-token model exists and no caller ever constructs it — entirely dead code today. - Admin dashboard-stats query bug.
GetDashboardStatsfiltersmodels.KYCby astatuscolumn that doesn't exist (the real column isverification_status) — this will error at query time. The stats ticker's equivalent query (runStatsTicker) uses the correct column name, so the bug is isolated to this one endpoint. - 3 of 5 seed/tool binaries bypass configured DB credentials.
create_demo_user,create_test_user, andseed_exchange_rateshardcode Postgres DSNs directly in source (two different, mutually inconsistent credential sets) instead of reading.envviaconfig.Load()— see Runbook → Seeds.