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).
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 |
⚠️ PSD2 SCA Gap:The 24h login session is acceptable for AISP (balance viewing) but non-compliant for PISP (payment initiation). PSD2 RTS Art. 4 requires per-transaction SCA with a 5-minute payment session window. SeeADR-017: PSD2 SCA Payment Sessionsfor the remediation design.
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).