Security Architecture
Security Architecture Document
Project:
{{PROJECT_NAME}}Drop — PSD2 Pass-Through Payment App Version:{{VERSION}}1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Security Architect Status: Draft| In Review | ApprovedReviewers:{{REVIEWERS}}CTO, DPO, Engineering Lead Classification: Confidential
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft from security audit + hardening implementation |
1. Security Architecture Overview
Security Owner: {{SECURITY_OWNER_ROLE}}CISO / Security Architect ({{CONTACT_EMAIL}})[email protected])
Last Security Review: {{DATE}}2026-02-13 (post-hardening)
Next Scheduled Review: {{DATE}}2026-08-23
Compliance Targets: GDPR |/ PCI-DSSpersonopplysningsloven | SOC2PSD2 Type/ IIbetalingstjenesteloven | ISOhvitvaskingsloven 27001(AML) | HIPAAIKT-forskriften / DORA | {{OTHER}}Finanstilsynet licensing
Architecture Model: Drop is a PSD2 pass-through payment app (AISP + PISP). 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).
Defense-in-Depth Overview
flowchart TB
subgraph Perimeter["Perimeter Security"]
DNS[DNS / DDoS Protection\nCloudflare / AWS Shield]WAF]
WAF[Web Application Firewall]Firewall\nCloudflare WAF Rules]
CDN[CDN — Edge TLS Termination]1.3 Termination\nCloudflare]
end
subgraph Network["Network Security"]
LB[LoadAWS Balancer\App Runner\nTLS 1.3]
SG[Security Groups / NACLs]
VPC[Private VPC — NoVPC\nNo public subnet\nforsubnet for data layer]
end
subgraph Application["Application Security"]
BANKID[BankID OIDC\nNorwegian eID — Level 4]
AUTH[AuthenticationJWT Auth Service\nJWTnHS256, +httpOnly MFA + Session]cookies]
AUTHZ[Authorization Layer\nRBAC user/merchant/admin + PolicyAND Engine]user_id=?]
VALID[Input Validation\nSchemanZod schemas + Sanitization]sanitizeText()]
RATE[Rate Limiting\nPernSQLite-backed, IP + Per user]per-IP]
end
subgraph Data["Data Security"]
ENCRYPT[Encryption at Rest\nAES-256]
TRANSIT[Encryption in Transit\nTLS 1.3 / mTLS]3]
MASK[PII Masking\nField-levelnLast-4 encryption]only for cards/bank accounts]
VAULT[Secrets Management\n{{VAULT_TOOL}}]nAWS Secrets Manager + JWT_SECRET env]
end
subgraph Monitoring["Security Monitoring"]
SIEM[SIEMSENTRY[Error /Monitoring\nSentry]
BETTERSTACK[Uptime + Log Aggregation]
IDS[Intrusion Detection]Monitoring\nBetterStack]
ALERTS[Security Alerts\nPagerDuty]nPagerDuty escalation]
AUDIT[Audit Trail\nImmutablenSession table + structured logs]
end
DNS --> WAF --> CDN --> LB
LB --> SG --> VPC
VPC --> BANKID --> AUTH --> AUTHZ --> VALID
VALID --> RATE
RATE --> Data
Data --> Monitoring
2. Authentication Flows
2.1 StandardBankID OIDC Authentication (Production Target)
sequenceDiagram
autonumber
actor User
participant App as Drop App
participant API as Drop API
participant BankID as BankID OIDC Provider
participant DB as User Store (PostgreSQL)
User->>App: Tap "Login with BankID"
App->>API: GET /auth/bankid/authorize
API->>BankID: Redirect with PKCE code_challenge, scope=openid+profile+nnin
User->>BankID: Authenticate with BankID (Level 4 — possession + knowledge + inherence)
BankID->>API: Redirect with authorization_code
API->>BankID: Exchange code for tokens (PKCE code_verifier)
BankID-->>API: {id_token, access_token} — contains fødselsnummer, name, birthdate
API->>API: Validate id_token signature + iss + aud + exp + nonce
API->>API: Extract name, fødselsnummer; verify age >= 18 (from birthdate)
API->>DB: Upsert user (name, phone, bankid_ref); check KYC status
API->>API: Generate JWT (HS256, 24h), create session record
API-->>App: Set httpOnly cookie (JWT) + 200 OK
App->>App: Render dashboard
2.2 Current Email/Password Login /(MVP Registration— pre-BankID)
sequenceDiagram
autonumber
actor User
participant FE as Drop Frontend
participant GWAPI as Drop API Gateway
participant AUTH as Auth Service(src/drop-app)
participant DB as UserSQLite Store/ participant EMAIL as Email ServicePostgreSQL
User->>FE: Enter email + password
FE->>GW:API: POST /api/auth/login {email, password}
GW-API->>GW:API: Rate limit check (510 attempts/15min/IP)req/60s GW-per IP — middleware.ts)
API->>AUTH:API: ValidateCSRF credentialsOrigin AUTH-header validation
API->>DB: SELECT user WHERE email = $1?
DB-->>AUTH:API: User record
AUTH-API->>AUTH:API: Verifybcrypt.compare(password, bcrypt/Argon2hash) hash— cost factor 12
alt Invalid credentials
AUTH-->>GW: 401 Unauthorized
GW->>AUTH: Log failed attempt (for lockout)
GW-API-->>FE: 401 Invalid credentials
Note over FE:— Generic message"Invalid —credentials" (no user enumerationenumeration)
end
alt MFA enabled
AUTH-API->>FE:DB: 200INSERT {mfa_required:INTO true,sessions session_id}(id, User-user_id, token_hash, expires_at, revoked=0)
API->>FE: Enter TOTP code
FE->>AUTH: POST /auth/mfa {session_id, totp_code}
AUTH->>AUTH: Verify TOTP
end
AUTH->>AUTH:API: Generate JWT accessHS256 +— refreshsetIssuedAt(), tokens24h AUTH->>DB:expiry
UPDATE last_login_at
AUTH-->>GW: {access_token, refresh_token, expires_in}
GW-API-->>FE: Set HttpOnlyhttpOnly=true, secure=true, sameSite=strict cookie (refresh) + access_token in body
FE->>FE: StoreRender access_tokendashboard (no token in memory onlylocalStorage)
2.23 Token Refresh Flow& Session Revocation
sequenceDiagram
autonumber
actor FE as Drop Frontend
participant GWAPI as Drop API Gateway
participant AUTH as Auth Service
participant DB as TokenSession Store
FE->>GW:API: POSTAny /auth/refreshauthenticated request (HttpOnlyhttpOnly cookie:cookie refresh_token)sent GW-automatically)
API->>AUTH:API: ValidateVerify refreshJWT tokensignature AUTH-+ expiry (jose library)
API->>DB: SELECT tokensessions WHERE valuetoken_hash = hash($1)SHA256(jwt) AND revoked = false
DB-->>AUTH: Token record
AUTH->>AUTH: Check expiry + rotation window0
alt TokenSession revoked or expired
or revoked
AUTH-API-->>FE: 401 — Re-authenticateForce re-login
end
AUTH-API->>API: Extract userId, proceed with request
Note over FE,DB: On logout — all sessions revoked
FE->>API: POST /api/auth/logout
API->>DB: RevokeUPDATE oldsessions refreshSET tokenrevoked=1 (rotation)WHERE AUTH->>AUTH:user_id Generate= new?
access_token + refresh_token
AUTH->>DB: Store new refresh token (hashed)
AUTH-API-->>FE: {access_token}Clear + Set new HttpOnlyhttpOnly cookie
2.34 Multi-FactorMFA — Dynamic Linking for PSD2 Compliance (Phase 2)
BankID inherently provides possession + knowledge (two factors). For PSD2 Strong Customer Authentication (MFA)
Methods supported:SCA):
| Notes | ||
|---|---|---|
BankID PIN / password |
||
Amount + payee shown in BankID signing dialog |
||
sequenceDiagramCurrent autonumber
actor User
participant AUTH as Auth Service
participant DB as DB
User->>AUTH: POST /auth/mfa/setupstate (authenticated)MVP): AUTH->>AUTH:Email/password Generateonly TOTP— secretno (160-bitSCA entropy)compliance. AUTH->>User:BankID QRintegration codeis +Phase backup codes (shown ONCE)
User->>AUTH: POST /auth/mfa/verify {totp_code} (confirm setup)
AUTH->>AUTH: Verify TOTP code
AUTH->>DB: UPDATE user SET mfa_secret = encrypt($secret), mfa_enabled = true
AUTH->>DB: INSERT backup_codes (hashed, 10 codes)
AUTH-->>User: MFA enabled
2.4 SSO / OAuth2 Flow
sequenceDiagram
autonumber
actor User
participant FE as Frontend
participant AUTH as Auth Service
participant IDP as Identity Provider\n(Auth0 / Keycloak / Azure AD)
User->>FE: Click "Sign in with {{PROVIDER}}"
FE->>AUTH: GET /auth/sso/{{provider}}
AUTH->>IDP: Redirect with PKCE code_challenge
User->>IDP: Authenticate with IDP
IDP->>AUTH: Redirect with authorization_code
AUTH->>IDP: Exchange code for tokens (PKCE code_verifier)
IDP-->>AUTH: {id_token, access_token}
AUTH->>AUTH: Validate id_token signature + claims
AUTH->>AUTH: Find or create local user account
AUTH-->>FE: Issue local JWT (same flow as login)
3. Authorization Model
Model: RBAC (Role-Based Access Control) with resource-levelmandatory conditionsuser-scoped data isolation
3.1 Roles & Permissions Matrix
| Permission | |||||
|---|---|---|---|---|---|
| ✓ | ✓ | ✓ | |||
| ✓ | ✓ | ||||
| ✓ | ✓ | ✓ | |||
| Manage recipients (own) | ✓ | ✓ | ✓ | ||
| View merchant dashboard | — | ✓ | ✓ | ||
| View merchant transactions | — | ✓ (own) | ✓ (all) | ||
| Manage merchant settings | — | ✓ (own) | ✓ | ||
| Admin functions | — | — | ✓ | ||
| — | — |
KYC Status Gates (required for financial operations):
| KYC Status | Remittance | QR Payment | Balance Read | ||
|---|---|---|---|---|---|
pending |
Blocked | Blocked | Blocked | ||
approved |
|||||
rejected |
3.2 Resource-Level Conditions (IDOR Protection)
All data access queries include mandatory user scoping. Source: src/drop-app/src/lib/:
// All recipient queries — IDOR prevention
db.prepare("SELECT * FROM recipients WHERE id = ? AND user_id = ?").get(id, userId);
// All transaction queries
db.prepare("SELECT * FROM transactions WHERE id = ? AND user_id = ?").get(id, userId);
// All bank account queries
db.prepare("SELECT * FROM bank_accounts WHERE user_id = ?").all(userId);
Merchant endpoints additionally verify:
- User has
role = 'merchant' - Merchant record belongs to authenticated user
3.3 Permission Hierarchy
SUPER_ADMINadmin (platform level — bypasses tenant isolation)
└── OWNER (tenant platform-level — full control within tenant)access)
└── ADMINmerchant (cantenant-level manage— users,own notdata billing)+ merchant dashboard)
└── MEMBERuser (standard CRUD on— own resources)data └── VIEWER (read-only)
└── API (scoped to specific permissions)
3.3 Resource-Level Conditions
Rule: Member can UPDATE {{resource}} IF:
resource.tenant_id == user.tenant_id AND
(resource.created_by == user.id OR user.role IN ['admin', 'owner'])
Rule: API key can READ {{resource}} IF:
api_key.tenant_id == resource.tenant_id AND
api_key.scopes CONTAINS '{{resource}}:read'
4. Data Encryption
4.1 Encryption at Rest
| Data Store | Encryption Method | Key Management |
|---|---|---|
| SQLite (current MVP) | OS-level disk encryption | Platform |
| PostgreSQL (Phase 2) | AES- |
|
| AES- |
||
| PostgreSQL — KYC documents | AES-256-GCM | AWS KMS |
| SSE-KMS (AES-256) | ||
| JWT_SECRET | AWS Secrets Manager | AWS-managed |
4.2 Encryption in Transit
TLS Configurationconfiguration (minimumCloudflare TLS+ 1.2,AWS preferApp TLS 1.3)Runner):
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
add_headerTLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
Strict-Transport-SecuritySecurity: "max-age=63072000; includeSubDomains; preload" always;preload
mTLS for internal service communication:
- All
service-to-serviceAPIcallstraffic:withinHTTPSthe cluster use mTLS Certificates managed by {{CERT_TOOL}}only (e.g.,HTTPcert-manager,→Istio,301Linkerd)redirect). CertificatehttpOnlyrotation:cookiesAutomaticwitheverysecure:{{ROTATION_PERIOD}}true
4.3 Field-Level Encryption for PII
Fields encrypted at application layer (before DB write):
- users.mfa_secretFødselsnummer → AES-256-GCM, key:encrypted USER_MFA_KEYimmediately -on users.{{PII_FIELD}}receipt →from AES-256-GCM,BankID
key:Key: USER_PII_KEYNATIONAL_ID_KEY -(AWS {{OTHER_SENSITIVE_FIELD}}KMS →— AES-256-GCM,separate key:from {{KEY_NAME}}database Envelopemaster encryptionkey)
pattern:Stored: Data →Only encrypted with DEK (Data Encryption Key)
DEK → encrypted with KEK (Key Encryption Key, stored in KMS)
Only DEK ciphertext stored in database
Read: Decrypted only for KYC checks, by compliance role only
Bank account numbers → masked in API responses (last 4 digits only)
Source: utils-server.ts:23-26 maskAccountNumber()
Card numbers → last_four + token_ref only (full PAN never stored)
Note: Cards feature gated behind feature flags, all default false
5. Network Security
5.1 Network Architecture
Internet
→ Cloudflare DDoS Protection ({{PROVIDER}})+ WAF
→ WAF ({{WAF_TOOL}}) — blocks OWASP Top 10
→Cloudflare CDN / Edge (TLS termination)1.3 termination at edge)
→ LoadAWS BalancerApp Runner (re-encryptsencrypts, toTLS app — TLS)1.3)
→ PublicApplication Subnetcontainers (only LB + NAT Gateway)Next.js)
→ Private SubnetDatabase (Application servers)SQLite → DataPostgreSQL Subnetin (DB,private Cache — no internet access)subnet)
5.2 Security Groups / Firewall Rules
| Source | Destination | Port | Protocol | Action |
|---|---|---|---|---|
| Internet | 443 | HTTPS | ALLOW | |
| Internet | Any | 80 | HTTP | REDIRECT → 443 |
| AWS App |
ALLOW | |||
| App |
PostgreSQL (Phase 2) | 5432 | TCP | ALLOW |
| Any | Any | Any | DENY |
|
| Any (unauthenticated) | /api/transactions/* | 443 | HTTPS | Rate limited (10/60s) |
5.3 WAF Rules (Cloudflare)
| Rule | Action | Notes |
|---|---|---|
| OWASP Core Rule Set | Block | |
| SQL injection | Block | Including encoded variants |
| XSS | Block | Including DOM-based |
| ||
| Bot traffic | Challenge |
Suspicious patterns |
| Rate limiting | Block | > |
| Geo-blocking |
6. API Security
6.1 Rate Limiting Strategy
Source: src/drop-app/src/lib/middleware.ts:6-31
| Limit | Window | Implementation | |
|---|---|---|---|
| SQLite-backed, per-IP | |||
| SQLite-backed, per-IP | |||
| SQLite-backed, per-IP |
Note (Medium finding M3): X-Forwarded-For header is trusted from Cloudflare. Must validate only from trusted proxy IPs in production.
6.2 Input Validation
Source: src/drop-app/src/lib/middleware/validation.ts
ALL// All text inputs validatedsanitized
at:sanitizeText(input) 1.→ APIremoves GatewayHTML leveltags, —control schemachars, typetrims, checkingenforces 2.maxLength
Controller// level — DTOFinancial validation
(class-validatorvalidateAmount(amount) → positive, finite, max 2 decimal places
Remittance: 100 NOK ≤ amount ≤ 50,000 NOK
QR Payment: 1 NOK ≤ amount ≤ 100,000 NOK
Number.isFinite() prevents NaN/Infinity injection
/ Zod / joi)
3. Service level — business ruleIdentity validation
4.validateIBAN(iban) Database→ level — constraintsformat + checkchecksum
constraintsvalidatePIN(pin) Never→ trustexactly client-supplied4 data.digits
Always:validateCurrency(currency) -→ Whitelistwhitelist: allowedEUR, charactersUSD, whereGBP, possibleBAM, -CHF, EnforcePLN, maxNOK, lengthRSD, onTRY, allPKR
stringvalidateLanguage(lang) fields→ -whitelist: Validatenb, fileen, typesbs, bysq
magic// bytes,SQL: notALL extension24 -endpoints Useuse parameterized queries — NEVERzero string concatenation for SQL
6.3 CORS Policy
// Production CORS config
{
origin: ['https://app.{{DOMAIN}}'getdrop.no', 'https://{{DOMAIN}}'app.getdrop.no'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
credentials: true, // Required for HttpOnlyhttpOnly cookies
maxAge: 86400 // 24h preflight cache
}
6.4 Content Security Policy
Source: src/drop-app/next.config.ts:6-46
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{NONCE}';
style-srcunsafe-inline' 'self' 'nonce-{NONCE}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.{{DOMAIN}}unsafe-eval';
frame-ancestors 'none';
form-action[remaining 'self';
upgrade-insecure-requests;headers...]
Known issue (Medium M1): unsafe-inline and unsafe-eval required for Next.js. Production target: nonce-based CSP.
7. OWASP Top 10 Mitigation Matrix
| OWASP Risk | Mitigation | Implementation | Status | |
|---|---|---|---|---|
| A01: Broken Access Control | RBAC + | AND user_id = ? in all data queries; middleware.ts |
✓ | |
| A02: Cryptographic Failures | TLS 1.3 + AES- |
✓ | ||
| A03: Injection | Parameterized queries |
✓ | ||
| A04: Insecure Design | ||||
| A05: Security Misconfiguration | ✓ (CSP partial) | |||
| A06: Vulnerable Components | ✓ | |||
| A07: Auth Failures | ✓ | |||
| A08: Software Integrity | Signed commits + |
Partial | ||
| A09: Logging Failures | ||||
| A10: SSRF |
8. Security Headers Checklist
Source: src/drop-app/next.config.ts
| Header | Value | Status |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
✓ |
Content-Security-Policy |
default-src |
✓ |
X-Content-Type-Options |
nosniff |
✓ |
X-Frame-Options |
DENY |
✓ |
Referrer-Policy |
strict-origin-when-cross-origin |
✓ |
Permissions-Policy |
|
✓ |
Cache-Control (auth responses) |
no-store |
|
X-XSS-Protection |
0 ( |
9. Dependency Vulnerability Management
Current dependency status (audit date: 2026-02-12):
| Package | Version | Risk | Notes |
|---|---|---|---|
jose |
^6.1.3 | Low | JWT library — well-maintained |
bcryptjs |
^3.0.3 | Low | Pure JS bcrypt — no native compilation |
better-sqlite3 |
^12.6.2 | Low | 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 |
Scanning tools:tools (planned):
- SAST:
{{SAST_TOOL}}Semgrep / CodeQL (runs on every PR) - SCA:
Snyk /Dependabot + npm audit (runs on every PR + daily on main) ContainerContainer:scanning:{{CONTAINER_SCANNER}}Trivy (runs on Docker image build)
Remediation SLAs:
| Severity | SLA | Owner |
|---|---|---|
| Critical (CVSS ≥ 9.0) | 24 hours | Security + affected team |
| High (CVSS 7.0-8.9) | 7 days | Affected team |
| Medium (CVSS 4.0-6.9) | 30 days | Affected team |
| Low (CVSS < 4.0) | 90 days | Next sprint |
10. Security Logging & Audit Trail
Current Securitymonitoring Eventsstack:
- Sentry: Error tracking, exception monitoring, performance
- BetterStack: Uptime monitoring, log aggregation, alerting
- Sessions table: Token revocation, session lifecycle
Planned (pre-production):
| Event | Data Logged | Retention | Alert? |
|---|---|---|---|
| Login success | user_id, ip, user_agent, timestamp | 1 year | No |
| Login failure | ip, email_hash, attempt_count, timestamp | 1 year | Yes (> 5 failures) |
| user_id, |
1 year | Yes (failure) | |
| Password change | user_id, ip, timestamp | 1 year | Yes |
| user_id, |
1 year | ||
| user_id, |
Yes (>50,000 NOK) | ||
| KYC status change | user_id, old_status, new_status, operator | 5 years | Yes |
| user_id, |
Yes ( |
||
| Admin action | actor_id, action, target, changes | 2 years | Yes |
Log format:retention (hvitvaskingsloven § 30): JSONMinimum structured,5 immutableyears for KYC and transaction data.
SIEM integration (append-only),Phase shipped to SIEM
SIEM:2): {{SIEM_TOOL}}BetterStack —→ dashboard:Sentry {{LINK}}→ TamperPagerDuty protection:escalation Logs signed with {{SIGNING_KEY}}, stored in write-once bucketchain.
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Security Architect | 2026-02-23 | |
| CISO / Security Lead | |||
| DPO | |||
| CTO |