Security Architecture
Drop Security Architecture
Last updated: 2026-02-14
Source: Security audit (security/drop-security-rapport.md), hardening implementation (security/security-hardening-implementation.md), source code
Architecture model: Drop uses a PSD2 pass-through model. It never holds customer money — AISP reads bank balances via Open Banking, PISP initiates payments from the user's bank account. Cards are a FUTURE feature, gated behind feature flags (all default to false).
BANKID_MOCK Production Guard
⚠️ CRITICAL: BANKID_MOCK must never be true in staging or production.
BANKID_MOCK=true bypasses all BankID OIDC verification, allowing any code value to authenticate as a test user. This completely disables identity verification — a critical security and regulatory failure.
Runtime Guards (fail-fast on startup)
Both services assert the environment is safe before accepting any traffic:
| File | Guard |
|---|---|
src/drop-api/src/lib/bankid.ts:40-41 |
throw Error if NODE_ENV=production && BANKID_MOCK=true |
src/drop-app/src/lib/services/mode.ts:39-41 |
throw Error if NODE_ENV=production && BANKID_MOCK=true |
If either service starts with BANKID_MOCK=true and NODE_ENV=production, the process throws immediately and App Runner marks the deployment as failed.
CI/CD Guards (pre-deploy check)
Both deploy workflows (deploy.yml, deploy-staging.yml) include a preflight step that fails the pipeline if BANKID_MOCK is set to true in GitHub repository variables or secrets:
- name: "Security: Assert BANKID_MOCK is not enabled"
run: |
if [ "${{ vars.BANKID_MOCK }}" = "true" ] || [ "${{ secrets.BANKID_MOCK }}" = "true" ]; then
echo "FATAL: BANKID_MOCK=true is not allowed in staging/production deploys!"
exit 1
fi
Correct Environment Configuration
| Environment | NODE_ENV |
BANKID_MOCK |
|---|---|---|
| Development | development |
true (optional — enables mock OIDC) |
| Staging | production |
not set (must be absent or false) |
| Production | production |
not set (must be absent or false) |
App Runner Environment Audit
When rotating App Runner environment variables, verify that BANKID_MOCK is not present in the production or staging service configuration. Use the AWS console or CLI:
aws apprunner describe-service --service-arn <ARN> \
--query 'Service.SourceConfiguration.CodeRepository.CodeConfiguration.CodeConfigurationValues.RuntimeEnvironmentVariables'
Authentication
JWT Token Management
Library: jose ^6.1.3 (well-maintained, no known vulnerabilities)
Algorithm: HS256 with explicit setProtectedHeader
Source: src/drop-app/src/lib/auth.ts
| Setting | Value | Source |
|---|---|---|
| Algorithm | HS256 | auth.ts |
| Expiry | 24 hours | auth.ts cookie maxAge: 60 * 60 * 24 |
setIssuedAt() |
Yes | Prevents token reuse |
| Secret (production) | JWT_SECRET env var |
Fatal error if missing |
| Secret (development) | process.cwd() hash |
Dev fallback for uniqueness |
Cookie Configuration
Source: src/drop-app/src/lib/auth.ts:48-54
| Property | Value | Purpose |
|---|---|---|
httpOnly |
true |
Prevents JavaScript access (XSS mitigation) |
secure |
true (production) |
HTTPS-only cookie transport |
sameSite |
"strict" |
CSRF prevention |
maxAge |
24 hours | Session lifetime |
path |
"/" |
Cookie scope |
Password Hashing
Library: bcryptjs ^3.0.3 (pure JS, no native compilation issues)
Source: src/drop-app/src/lib/utils-server.ts:8-16
| Setting | Value |
|---|---|
| Algorithm | bcrypt |
| Cost factor | 12 |
| SHA-256 fallback | Removed (security fix C4) |
After hardening, verifyPassword() only accepts bcrypt hashes. SHA-256 legacy support has been removed entirely.
Session Management
Source: src/drop-app/src/lib/auth.ts:45-65, src/lib/middleware.ts:42-77
Sessions are tracked in the sessions table:
| Column | Type | Purpose |
|---|---|---|
id |
TEXT PK | Session identifier (format: ses_<hex16>) |
user_id |
TEXT FK | References users.id |
token_hash |
TEXT | SHA-256 hash of JWT token |
expires_at |
TEXT | Expiration timestamp |
revoked |
INTEGER | 0 = active, 1 = revoked |
Lifecycle:
- Login -- Session created with token hash (
auth.ts:56-65) - Each request -- Session checked for revocation (
middleware.ts:66-74) - Logout -- All user sessions revoked server-side (
auth/logout/route.ts:5-14)
Authorization
IDOR Protection
All data access queries include AND user_id = ? to scope data to the authenticated user:
- Recipients: scoped to user
- Cards: scoped to user
- Transactions: scoped to user
- Bank accounts: scoped to user
- Notifications: scoped to user
- Settings: scoped to user
Merchant endpoints verify merchant role and ownership.
Role-Based Access
Roles (from lib/db.ts schema CHECK constraint):
user-- Standard usermerchant-- Merchant with dashboard access
KYC Status (required for financial operations):
pending-- Default on registrationapproved-- Required for remittancerejected-- Blocked from financial operations
Input Validation
Rate Limiting
Source: src/drop-app/src/lib/middleware.ts:6-31
| Endpoint Type | Limit | Window |
|---|---|---|
| Auth routes (login, register) | 10 requests | 60 seconds |
| Transaction routes (remittance, qr-payment) | 10 requests | 60 seconds |
| Rate endpoints (/api/rates) | 120 requests | 60 seconds |
Implementation: PostgreSQL-backed persistent rate limiting (survives restarts). Per-IP tracking using X-Forwarded-For header. (ADR-014: SQLite removed 2026-03-03)
Rate limit table (rate_limits):
key-- IP address (TEXT PK)count-- Request count (INTEGER)reset_at-- Window expiry (INTEGER, Unix timestamp)
CSRF Protection
Source: src/drop-app/src/lib/middleware.ts:44-55
Origin header validation on all authenticated requests:
- Validates
Originheader against allowed origins list - Allowed origins:
NEXT_PUBLIC_APP_URL,http://localhost:3000,http://localhost:3001 - Combined with
sameSite: "strict"cookies
Input Sanitization
Source: src/drop-app/src/lib/middleware/validation.ts:149-203
sanitizeText()-- Removes HTML tags, control characters, trims whitespace, enforces max lengthvalidateName()-- Rejects XSS payloads, script tags, numbers-only namesvalidateEmail()-- Regex validation for email formatvalidatePhone()-- International format validationvalidateAmount()-- Positive, finite, max 2 decimal placesvalidateIBAN()-- Format and checksum validationvalidatePIN()-- Exactly 4 digitsvalidateCurrency()-- Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKRvalidateLanguage()-- Whitelist: nb, en, bs, sq
Applied to: recipients, merchants, settings, notifications.
Amount Validation
| Endpoint | Min | Max | Source |
|---|---|---|---|
| Remittance | 100 NOK | 50,000 NOK | transactions/remittance/route.ts |
| QR Payment | 1 NOK | 100,000 NOK | transactions/qr-payment/route.ts |
Additional checks: Number.isFinite() to prevent NaN/Infinity injection. Pagination limited to max 50 per page.
SQL Injection Prevention
All 24 API endpoints use parameterized queries exclusively (? placeholders). No string concatenation in SQL statements.
Example from transactions/route.ts:
// Dynamic WHERE clauses use parameter arrays
const conditions: string[] = [];
const params: unknown[] = [];
if (type) { conditions.push("type = ?"); params.push(type); }
Merchant dashboard uses strict whitelist for period parameter.
Security Headers
Source: src/drop-app/next.config.ts:6-46
| Header | Value |
|---|---|
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; ... |
| X-Frame-Options | DENY |
| X-Content-Type-Options | nosniff |
| Referrer-Policy | strict-origin-when-cross-origin |
| Permissions-Policy | camera=(self), microphone=(), geolocation=(self) |
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
Known limitation: CSP includes unsafe-inline and unsafe-eval (required for Next.js dev mode). Should be tightened with nonce-based CSP for production.
API Response Masking
- Card numbers: Masked as
**** **** **** XXXXin responses (cards/[id]/route.ts:25-35) - CVV: Displayed as
***in responses - Bank accounts: Only last 4 digits visible (
utils-server.ts:23-26)
Transaction Integrity
Source: transactions/remittance/route.ts, transactions/qr-payment/route.ts
- Atomic balance deduction using PostgreSQL transactions (Drizzle ORM)
WHERE balance >= $1prevents overdraw- PostgreSQL MVCC with explicit
FOR UPDATErow locking - Fee calculated and included in deduction
Feature Flags
Source: src/drop-app/src/lib/feature-flags.ts
Gated features (disabled by default):
| Flag | Env Var | Default |
|---|---|---|
virtualCards |
NEXT_PUBLIC_FF_VIRTUAL_CARDS |
false |
physicalCards |
NEXT_PUBLIC_FF_PHYSICAL_CARDS |
false |
cardDetails |
NEXT_PUBLIC_FF_CARD_DETAILS |
false |
cardFreeze |
NEXT_PUBLIC_FF_CARD_FREEZE |
false |
cardPin |
NEXT_PUBLIC_FF_CARD_PIN |
false |
spendingLimits |
NEXT_PUBLIC_FF_SPENDING_LIMITS |
false |
notifications |
NEXT_PUBLIC_FF_NOTIFICATIONS |
true |
merchantDashboard |
NEXT_PUBLIC_FF_MERCHANT_DASHBOARD |
true |
API routes check feature flags and return 404 when disabled.
Dependency Security
| Package | Version | Risk Assessment |
|---|---|---|
jose |
^6.1.3 | Low -- Well-maintained JWT library |
bcryptjs |
^3.0.3 | Low -- Pure JS bcrypt |
drizzle-orm |
latest | Low -- Type-safe ORM, parameterized queries |
next |
16.1.6 | Low -- Recent version |
react |
19.2.3 | Low -- Latest major |
radix-ui |
^1.4.3 | Low -- UI components only |
No known vulnerable dependencies identified (from security/drop-security-rapport.md:400-411).