BlockbotX Security Architecture
Overview
BlockbotX implements a security-first architecture covering authentication, encryption, rate limiting, input validation, and monitoring. Every layer is designed with defense-in-depth: no single control is relied upon in isolation. Tokens are stored in httpOnly cookies, API keys are encrypted at rest with AES-256-GCM, rate limiting is backed by Redis, and a real-time security logger detects and auto-blocks anomalous activity.
Authentication
JWT Token System
BlockbotX uses a dual-token JWT architecture:
- Access tokens: 15-minute expiry (configurable via
JWT_EXPIRES_INenv var). CarriesuserId,email,role,status,emailVerified, andtype: "access". - Refresh tokens: 7-day expiry (configurable via
REFRESH_TOKEN_EXPIRES_INenv var). Carries the same payload withtype: "refresh". Stored as aSessionrecord in the database. - Issuer/audience: Both tokens are signed with
issuer: "blockbotx"andaudience: "blockbotx-api", verified on every decode. - Storage: Tokens are stored in httpOnly cookies (not localStorage). The
accessTokencookie usessameSite: "lax"with a 15-minutemaxAge. TherefreshTokencookie usessameSite: "strict"with a 7-daymaxAge. Both are markedsecure: truein production. - API clients: Also supports
Authorization: Bearer <token>header as a fallback. The auth middleware checks the cookie first, then falls back to the header.
Token payload interface:
interface JwtPayload {
userId: string;
email: string;
role?: string;
status?: string;
emailVerified?: boolean;
type: "access" | "refresh";
}
Verification: verifyToken() validates signature, issuer, and audience. Returns null for expired or invalid tokens. Distinguishes between TokenExpiredError and JsonWebTokenError in logging.
Source: lib/auth/jwt.ts
Login Flow
The login endpoint (POST /api/auth/login) executes the following steps in order:
-
Zod schema validation -- Request body is validated against
loginSchema. Invalid input returns 400 with the first error message. -
User lookup -- Email is normalized to lowercase. User record is fetched with all fields needed for authentication checks (password hash, 2FA settings, status, lock state, failed attempts).
-
Account status check -- Checks for
banned(403, permanent),disabled(403, permanent), orsuspended(403, temporary). Expired suspensions are auto-cleared by resetting status toactive. -
Lock check -- If
lockedUntilis set and in the future, returns 429 with remaining minutes. Lockout is triggered after 5 failed login attempts and lasts 30 minutes. -
Password verification -- bcrypt comparison of submitted password against stored hash.
-
Failed attempt increment -- On password failure,
failedLoginAttemptsis incremented. At 5 failures,lockedUntilis set to 30 minutes from now. ALoginHistoryrecord is created withsuccess: false. -
2FA check -- If
twoFactorEnabledis true and nototpTokenis provided, returns{ require2FA: true }with status 200. The client must re-submit with the TOTP code. -
TOTP validation -- If 2FA is enabled and a
totpTokenis provided, verifies against the stored secret. Invalid codes increment failed attempts and may trigger lockout. -
Token pair generation -- On successful authentication, generates both access and refresh tokens via
generateTokenPair(). Failed attempt counter is reset to 0. -
Session creation -- A
Sessionrecord is created in the database with the refresh token, 7-day expiry, IP address, and user agent. -
Login history and device fingerprint -- A successful
LoginHistoryrecord is created. A SHA-256 hash of the User-Agent string serves as a device fingerprint. If the fingerprint is not found in theKnownDevicetable, a new device record is created and a new-device alert email is sent (fire-and-forget). Known devices have theirlastUsedAttimestamp updated. -
Set cookies -- The response sets
accessToken(httpOnly, sameSite lax, 15-minute maxAge) andrefreshToken(httpOnly, sameSite strict, 7-day maxAge) cookies. The access token is also returned in the response body for API client convenience.
Source: app/api/auth/login/route.ts
Password Security
- Hashing: bcrypt with 12 salt rounds (via
bcrypt.hash()). - Validation requirements: Minimum 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number, at least one special character.
- Secure token generation: 32-byte
crypto.randomBytes()for password reset tokens and email verification tokens, output as hex (64 characters).
Source: lib/auth/password.ts
Auth Middleware
Two layers of auth middleware exist:
lib/auth/middleware.ts -- Core token extraction and verification:
getAuthUser(req)extracts the token from theaccessTokencookie orAuthorization: Bearerheader, verifies it, and ensurestype === "access". ThrowsUnauthorizedErroron failure.getOptionalAuthUser(req)wrapsgetAuthUser()and returnsnullinstead of throwing.requireRole(userRole, requiredRole)enforces a role hierarchy:user (1) < admin (2) < superadmin (3).
lib/security/auth-middleware.ts -- Route-level wrappers:
requireAuth(handler)wraps a route handler, callinggetAuthUser()and attaching the user to the request via aWeakMap(avoids type-unsafereq.userassignment).optionalAuth(handler)attempts authentication but does not reject unauthenticated requests.requireRole(roles)returns a middleware that verifies the authenticated user has one of the specified roles.composeMiddleware(...middlewares)composes multiple wrappers in left-to-right order usingreduceRight.getUserFromRequest(req)retrieves the user attached byrequireAuth.
Source: lib/auth/middleware.ts, lib/security/auth-middleware.ts
Two-Factor Authentication (2FA)
- Library:
otpauth(TOTP implementation) - Algorithm: SHA1, 6 digits, 30-second period
- Secret: 20-byte random secret, encoded as base32
- Clock drift tolerance: Window of 1 (accepts tokens from 1 period before or after current, i.e., +/-30 seconds)
- Setup flow:
generateTOTPSecret(userEmail)creates a TOTP instance with issuer "BlockbotX" and returns the base32 secret andotpauth://URI.generateTOTPQRCode(otpauthUrl)renders the URI as a PNG data URL via theqrcodelibrary. - Verification:
verifyTOTPToken(token, secret)validates a 6-digit code against the stored secret. Returnstrueiftotp.validate()returns a non-null delta. - Backup codes:
generateBackupCodes(count = 10)generates codes inXXXX-XXXXformat using uppercase alphanumeric characters. Codes are bcrypt-hashed before storage and are one-time use.
Source: lib/auth/totp.ts
Encryption
- Algorithm: AES-256-GCM (authenticated encryption with associated data)
- Key: 32-character
ENCRYPTION_KEYenvironment variable, validated on first use (lazy initialization allowsnext buildto succeed without the key). Throws a fatal error if the key is missing or not exactly 32 characters. - IV: 12-byte (96-bit) random IV generated via
crypto.randomBytes(12)for each encryption call, ensuring unique ciphertext even for identical plaintext. - Output format:
iv:authTag:encryptedData(all hex-encoded, colon-separated) - Decryption: Parses the three-part format, reconstructs the decipher with the auth tag, and returns the plaintext. Invalid formats or tampered ciphertext cause decryption failure.
- Used for: Exchange API keys (Binance, OKX), exchange secret keys, OKX passphrase. Keys are decrypted only at the point of use and never cached in memory.
- Hash utility:
hash(text)produces a SHA-256 hex digest.verifyHash(text, hashedText)usescrypto.timingSafeEqual()to prevent timing attacks during comparison. - Key generation:
generateEncryptionKey(length = 32)produces a cryptographically secure random key usingcrypto.randomBytes().
Source: lib/encryption/api-keys.ts, lib/encryption/index.ts
Rate Limiting
Rate limiting is Redis-backed using express-rate-limit with rate-limit-redis (RedisStore). Four tiers are defined:
| Tier | Limit (prod) | Window | Key | Applied To |
|---|---|---|---|---|
| API | 100 requests | 15 min | IP | All API routes (default) |
| Auth | 5 requests | 15 min | IP | Login, register, forgot-password, reset-password, 2FA routes. skipSuccessfulRequests: true so only failures count. |
| Strict | 3 requests | 1 hour | IP | Password reset and other sensitive operations |
| User | 1000 requests | 1 hour | JWT userId (fallback to IP) | Per authenticated user across all routes |
Key details:
- In development/test, limits are relaxed (e.g., API: 10,000 instead of 100) to avoid blocking during development.
- JWT user extraction reads from the
Authorizationheader oraccessTokencookie viaextractUserIdFromJWT(), verifies the token, and extractsuserId. Falls back to IP for anonymous users. - IP extraction normalizes IPv6 addresses via
ipKeyGenerator()from express-rate-limit. Readsx-forwarded-for,x-real-ip, orreq.ip. - Server-level rate limiting is applied in
server.tsbefore every request viaapplyRateLimit(). - Route-specific stricter limits can be applied via the
withRateLimit(handler, limiter)wrapper for individual API routes. getLimiterForPath(url)selects the appropriate limiter based on URL pattern (auth routes getauthLimiter, all others getapiLimiter).- 429 responses return
{ error: "Too many requests. Please try again later." }.
Source: lib/security/rate-limiter.ts
CSRF Protection
- Library:
csrfpackage - Secret storage: Generated via
tokens.secretSync()and stored in acsrf-secrethttpOnly cookie with a 24-hour maxAge,sameSite: "strict", andsecure: truein production. - Token generation:
generateCsrfToken()reads the secret from the cookie (or generates a new one), then callstokens.create(secret)to produce a per-request token. - Verification: The client sends the token in the
x-csrf-tokenheader.verifyCsrfToken(req)reads both the secret from the cookie and the token from the header, then callstokens.verify(secret, token). - Safe methods skipped: GET, HEAD, OPTIONS are not subject to CSRF verification.
- Route-level application:
withCsrfProtection(handler)wraps individual route handlers. Returns 403 with{ error: "Invalid or missing CSRF token" }on failure.
Note: CSRF protection is applied at the route level (not in the proxy) to avoid Edge runtime compatibility issues with the csrf package.
Source: lib/security/csrf.ts
Content Security Policy (CSP)
The CSP header is generated per-request in proxy.ts with a unique nonce derived from crypto.randomUUID():
default-src 'self';
script-src 'self' 'nonce-{nonce}' 'strict-dynamic' https://js.stripe.com;
style-src 'self' 'nonce-{nonce}';
img-src 'self' data: https: blob:;
font-src 'self' data:;
connect-src 'self' https://api.binance.com wss://stream.binance.com https://api.stripe.com https://*.ingest.sentry.io;
frame-src 'self' https://js.stripe.com;
media-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
upgrade-insecure-requests;
'strict-dynamic'allows scripts loaded by trusted (nonced) scripts to execute without explicit whitelisting.'unsafe-eval'is added toscript-srconly in development mode (required by Next.js HMR and React DevTools).'unsafe-inline'is added tostyle-srconly in development mode (production uses nonce).- Development mode also adds
wss://localhost:3000andws://localhost:3000toconnect-srcfor WebSocket HMR. - The nonce is passed to pages via the
x-noncerequest header.
Source: proxy.ts
Security Headers
Applied by proxy.ts on every response via addSecurityHeaders():
| Header | Value |
|---|---|
Content-Security-Policy | Per-request nonce-based policy (see above) |
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
Strict-Transport-Security | max-age=31536000; includeSubDomains (production only) |
Source: proxy.ts
Input Sanitization
Server-side sanitization is powered by DOMPurify running on jsdom:
| Function | Purpose |
|---|---|
sanitizeText(text) | Strips all HTML tags, returns plain text only |
sanitizeHtml(dirty) | Allows safe tags: b, i, em, strong, a, p, br, ul, ol, li, code, pre. Allowed attributes: href, title, target. Data attributes disabled. |
sanitizeSearchQuery(query) | Removes non-word characters (except hyphens and spaces), limits to 100 characters. Adds defense-in-depth on top of Prisma's built-in SQL injection protection. |
sanitizeObject(obj) | Recursively walks all properties and applies sanitizeText() to every string value. Handles nested objects and arrays. |
sanitizeEmail(email) | Trims, lowercases, validates against email regex, then applies sanitizeText(). Returns null if invalid. |
sanitizeUrl(url) | Parses with new URL(), allows only http:, https:, and mailto: protocols. Rejects javascript:, data:, and other dangerous schemes. Returns null if invalid. |
sanitizeFilename(filename) | Removes path traversal characters (../), strips leading dots, allows only a-zA-Z0-9._-, limits to 255 characters. |
sanitizeNumber(value, min?, max?) | Validates numeric input, checks isNaN/isFinite, enforces optional bounds. Returns null if invalid. |
sanitizeBoolean(value) | Converts string/number/boolean inputs to strict boolean. Accepts "true", "1", "yes" as true and "false", "0", "no" as false. Returns null for unrecognized inputs. |
sanitizeJson(jsonString) | Parses JSON, recursively sanitizes all string values via sanitizeObject(), re-serializes. Returns null if invalid JSON. |
Source: lib/security/sanitize.ts
CORS Configuration
- Allowed origins: Populated from
NEXT_PUBLIC_APP_URLandCORS_ALLOWED_ORIGINS(comma-separated) environment variables. - Development:
http://localhost:3000andhttp://localhost:3001are automatically added. - Credentials:
Access-Control-Allow-Credentials: trueis only set when the request origin matches the whitelist. - Allowed methods:
GET, POST, PUT, DELETE, OPTIONS - Allowed headers:
Content-Type, Authorization, x-csrf-token - Preflight cache:
Access-Control-Max-Age: 86400(24 hours) - OPTIONS preflight: Returns 204 with CORS headers.
- Route-level application:
withCors(handler)wraps route handlers, handles preflight automatically, and adds CORS headers to all responses.
Source: lib/security/cors.ts
Security Monitoring and Logging
Security Logger
Security events are logged to both Winston (for immediate visibility) and the SecurityLog database table (for long-term tracking and analysis).
Event types:
auth_failure-- Failed login attempts, invalid tokensrate_limit-- Rate limit violationscsrf_failure-- Invalid or missing CSRF tokenssuspicious_activity-- Anomalous patterns detectedunauthorized_access-- Attempts to access protected resources without authorization
Event structure:
interface SecurityEvent {
type: 'auth_failure' | 'rate_limit' | 'csrf_failure' | 'suspicious_activity' | 'unauthorized_access';
userId?: string;
ip: string;
userAgent: string;
details: Record<string, unknown>;
}
Anomaly Detection
checkForAnomalies() runs automatically after every logged security event. It counts recent events of the same type from the same IP within the last 15 minutes and compares against defined thresholds:
| Event Type | Threshold |
|---|---|
auth_failure | 5 |
rate_limit | 3 |
csrf_failure | 3 |
suspicious_activity | 2 |
unauthorized_access | 3 |
When a threshold is breached:
- A high-severity security alert is created in the database.
- Admin users are notified via the notification service (
sendSecurityAlertToAdmins()).
Auto-Blocking
When event count reaches 10x the threshold (e.g., 50 auth failures from one IP in 15 minutes):
- The IP address is automatically blocked for 24 hours via the
BlockedIPdatabase table. - A critical-severity alert is created and admins are notified.
- Duplicate blocks are prevented by checking for existing entries.
Security Statistics
getSecurityStats() provides dashboard metrics:
- Last 24 hours: auth failures, rate limits, CSRF failures, total
- Last 7 days: auth failures
- All time: total events
Log Retention
cleanupOldSecurityLogs(days = 90) deletes logs older than the specified retention period. Security alerts (type: "security_alert") are preserved permanently.
Source: lib/security/security-logger.ts
Account Security
- Account lockout: 5 failed login attempts triggers a 30-minute lock. The
lockedUntiltimestamp is checked on every login attempt. The counter resets on successful login. - Device fingerprinting: SHA-256 hash of the full User-Agent string. Stored in the
KnownDevicetable with@@unique([userId, deviceFingerprint]). - New device alerts: On login from an unrecognized device, an alert email is sent (fire-and-forget) with the device name (parsed from User-Agent), IP address, and login time.
- Session management: Each login creates a
Sessionrecord with the refresh token, expiry, IP, and user agent. Users can view and revoke active sessions. - Login history: Every login attempt (successful or failed) is recorded in the
LoginHistorytable with IP address, user agent, success status, and failure reason. - Session expiry: 7 days, consistent across all components (JWT refresh token, database session, refresh token cookie).
- Account status enforcement: Banned/disabled accounts are rejected at login. Suspended accounts with expired
suspendedUntilare automatically reactivated. The proxy also checks token status and clears cookies for banned/disabled users on page navigation. - Force password reset: If
forcePasswordResetis true on the user record, the login response includes a flag so the client can redirect to the password reset flow.
Proxy (Next.js 16 Convention)
The proxy.ts file (Next.js 16 convention, replaces middleware.ts) runs on every request and provides:
- Maintenance mode -- Checks system status via an internal API call (cached for 30 seconds). Non-admin users are redirected to
/maintenance. Essential API routes (/api/system/status,/api/auth/login,/api/health) are exempt. - Authentication redirects -- Unauthenticated users accessing protected routes are redirected to
/signinwith afromquery parameter for post-login redirect. Authenticated users accessing auth pages are redirected to/dashboard(or/verify-emailif unverified). - Email verification enforcement -- Unverified users are redirected to
/verify-emailfor all protected routes. - Admin route protection -- Defense-in-depth check:
/adminroutes verify the token hasadminorsuperadminrole. Real enforcement is in API route handlers. - Security headers -- Applied to every response (see Security Headers section above).
- CSP nonce generation -- A unique nonce is generated per request and passed to pages via the
x-nonceheader.
Token verification in the proxy uses jose (jwtVerify) for Edge runtime compatibility, separate from the jsonwebtoken-based verifyToken() used in API routes.
Source: proxy.ts
Logger Redaction
The Winston logger includes a custom redactFormat that automatically filters sensitive fields from all log metadata:
Sensitive field patterns (case-insensitive, matches both camelCase and snake_case):
api_key,api_secret,secret_keypassword,passwdtoken,access_token,refresh_tokenauthorization,cookieprivate_key,encryption_keywebhook_secret,jwt_secretcredentials
Matching field values are replaced with [REDACTED]. The redaction is applied recursively to nested objects and arrays. Fields named level, message, timestamp, and stack are preserved.
Production logging: Structured JSON output to logs/error.log (error level) and logs/combined.log (all levels), both with 5MB rotation and 5-file retention.
Source: lib/logger.ts
Environment Variables (Security-Related)
| Variable | Purpose | Requirements |
|---|---|---|
JWT_SECRET | Signs all JWT tokens | Must not be the default placeholder value in production |
JWT_EXPIRES_IN | Access token lifetime | Default: 15m |
REFRESH_TOKEN_EXPIRES_IN | Refresh token lifetime | Default: 7d |
ENCRYPTION_KEY | AES-256-GCM key for API key encryption | Must be exactly 32 characters |
NEXT_PUBLIC_APP_URL | Primary allowed CORS origin | Required |
CORS_ALLOWED_ORIGINS | Additional allowed CORS origins | Comma-separated, optional |
STRIPE_WEBHOOK_SECRET | Stripe webhook HMAC-SHA256 verification | Required for payment processing |