MyNEX Docs / Security
Argon2id · JWT · RBAC
Auth, PIN, RBAC, and where the edges are

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.

Login / Register issueTokens() Access token (15m) + Refresh token (7d, jti in Redis) Session row created Every protected request blacklist check → ValidateAccessToken → Locals(userID, kycStatus, ...) POST /auth/refresh old jti revoked immediately new jti minted, Session.TokenID rotated (only if not revoked) POST /auth/logout(-all) blacklist current access token revoke refresh jti(s) + Session(s) publish session.revoked Admin auth (separate handler) same JWT secret + signer, no aud claim SessionID=0 — no Session row, no admin session list/revoke exists
Trust boundary for "is this an admin token" is entirely: does an active AdminUser row exist for this JWT's subject UUID.

Token details

FieldValue
Access token TTLJWT_ACCESS_TOKEN_EXPIRY, default 15m
Refresh token TTLJWT_REFRESH_TOKEN_EXPIRY, default 168h (7 days)
SigningHS256, JWT_SECRET — shared by user and admin tokens (see below)
Claimsuser_id, user_uuid, email, token_type, verified, kyc_status, 2fa_enabled, session_id + registered claims (exp/iat/nbf, iss:"mynex", sub:UUID, jti)
Refresh rotationEvery refresh mints a brand-new jti and immediately revokes the old one — a used-and-discarded refresh token cannot be replayed
Revocation storeRedis: blacklist:<raw access token> (access), refresh_token:<userID>:<jti> (refresh) — checked on every request through AuthMiddleware / AdminAuthMiddleware
Session bindingSoft — 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
Admin and user tokens share one secret

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.

Hashing
Argon2id — memory 64 MB, iterations 3, parallelism 2, salt 16 bytes, key 32 bytes (identical to password hashing)
Format check
Exactly 6 digits, and rejects weak PINs: all-repeated (111111), strictly ascending (123456), strictly descending (654321)
Lockout
3 failed attempts → 15-minute lock (pin:lock:<userID> in Redis); attempt counter (pin:attempts:<userID>) has its own 15-minute sliding TTL from the first failure
Set
Requires account password re-entry; 409 DUPLICATE if a PIN is already set
Change
Requires the current PIN (goes through the same lockout path) plus the new PIN's format/weakness checks
internal/pinguard/pinguard.go
const (
    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.

support (1)→ finance (2)→ compliance (3)→ admin (4)→ super_admin (5)
Min roleRoute 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
Blueprint vs. code: role names differ

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.

Two RBAC systems coexist

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 approverRequestedBy == adminID returns 403 FORBIDDEN. Approval, ledger posting, wallet balance update, and the audit log write all happen inside one DB transaction.

Mobile security

ControlStatus
Biometric auth (Face ID / Touch ID / fingerprint)Implemented — local_auth, wrapped in BiometricService
App lock / auto-lockImplemented — 5 minute default, driven by AppLifecycleState; overlays a privacy curtain when backgrounded so balances don't appear in the OS app-switcher snapshot
Secure storageImplemented — 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 detectionImplemented — flutter_jailbreak_detection, blocks app start on a compromised device (fails open if the check itself is unavailable)
Certificate pinningImplemented 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 TOTPA 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

KeyPurposeSandbox default
ENCRYPTION_KEYAES-256-GCM master key (SHA-256-derived) for card PANs and, via CARD_ENCRYPTION_KEY's fallback chain, shared with card-serviceFalls back to a literal dev string if unset — fine for sandbox, must be set in production
SOLANA_WALLET_PRIVATE_KEY / POLYGON_WALLET_PRIVATE_KEYTreasury signing key per chain — genuinely AES-256-GCM encrypted at rest (verified: real decryption code, not a placeholder), key derived from ENCRYPTION_KEYUnused — sandbox chain needs no key at all
CARD_WEBHOOK_SECRETHMAC-SHA256 for /webhooks/cards/sandboxFalls back to a dev literal if unset
PAYMENTS_SANDBOX_WEBHOOK_SECRET / LOCAL_BANK_WEBHOOK_SECRETHMAC for payment provider webhooksSandbox has a fixed default; local_bank falls back to the gateway API key
JWT_SECRET / ADMIN_JWT_SECRETToken signingDefault 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 a time.Sleep-backed goroutine, not the durable sweeper pattern top-ups use — explicitly flagged in docs/wiring/payments.md as 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. CardProcessor and PartnerRail each 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 gap docs/wiring/payments.md notes explicitly and which is still open.
  • Push notifications are wired nowhere. PushService has 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. GetDashboardStats filters models.KYC by a status column that doesn't exist (the real column is verification_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, and seed_exchange_rates hardcode Postgres DSNs directly in source (two different, mutually inconsistent credential sets) instead of reading .env via config.Load() — see Runbook → Seeds.