Security Architecture
Security Architecture Document
Project:
DropBilko —PSD2BalkanPass-ThroughAccountingPayment AppSaaS Version: 1.0 Date: 2026-02-23 Author:SecurityCompliance Architect Status: Draft Reviewers: CTO, DPO, Engineering Lead Classification: Confidential
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | Initial draft |
1. Security Architecture Overview
Security Owner: CISO / SecurityCompliance Architect ([email protected])[email protected])
Last Security Review: 2026-02-13 (post-hardening)23
Next Scheduled Review: 2026-08-23
Compliance Targets: GDPR /| personopplysningslovenZakon o zaštiti podataka o ličnosti RS (ZZPL) | PSD2Zakon /o betalingstjenestelovenzaštiti ličnih podataka BiH (ZZLP) | hvitvaskingslovenGDPR (AML)HR) | IKT-forskriftenZakon /o DORAračunovodstvu RS | FinanstilsynetZakon licensingo PDV BiH | Zakon o PDV HR
Architecture Model: DropBilko is a PSD2multi-tenant pass-throughcloud paymentaccounting app (AISP + PISP).SaaS. It neverprocesses holdsinvoices, customerexpenses, money.VAT AISPreturns, readsand bankfinancial balancesreports viaon Openbehalf Banking.of PISPorganizations initiatesin paymentsSerbia, fromBosnia & Herzegovina, and Croatia. Each organization's data is strictly scoped by organizationId at the user'sdatabase bank account. Cards are a future feature gated behind feature flags (all default to false).layer.
Defense-in-Depth Overview
flowchartgraph TBTD
CLIENT["Client Browser / PWA"]
subgraph Perimeter[NETWORK["PerimeterNetwork Security"Layer"]
DNS[DNSCF["Cloudflare / DDoSWAF\nDDoS Protection\nCloudflare WAF]
WAF[Web Application Firewall\nCloudflare WAF Rules]
CDN[CDN — Edge TLSnTLS 1.3 Termination\nCloudflare]termination\nHSTS"]
end
subgraph Network[APP_LAYER["NetworkApplication Security"Layer"]
LB[AWSHELMET["Helmet.js\nCSP App+ Runner\nTLSX-Frame 1.3]+ SG[SecurityHSTS\nno GroupsX-Powered-By"]
CORS["CORS Whitelist\nbilko.io only\nno wildcard *"]
RATE["Rate Limiter\nexpress-rate-limit\n5 req/15min auth\n100 req/15min general"]
AUTH_MW["Auth Middleware\nJWT verify (15min access)\norg-scope injection"]
RBAC_MW["RBAC Middleware\nowner / NACLs]admin VPC[Private/ VPC\nNoaccountant public/ subnetviewer"]
forZOD["Zod dataValidation\nall layer]request bodies\ntype-safe parsing"]
end
subgraph Application[DATA_LAYER["ApplicationData Security"Layer"]
BANKID[BankIDPRISMA_ORM["Prisma OIDC\nNorwegianORM\nparameterized eIDqueries\nno —raw LevelSQL 4]for AUTH[JWTuser Authinput\norg-scoped Service\nHS256,WHERE httpOnly cookies]
AUTHZ[Authorization Layer\nRBAC user/merchant/admin + AND user_id=?clauses"]
VALID[InputPG_ENC["PostgreSQL Validation\nZod(Railway schemasEU)\nAES-256 +disk sanitizeText()encryption\nbackup encryption"]
RATE[Rate Limiting\nSQLite-backed, per-IP]
end
subgraph Data[AUDIT["DataAudit Security"Layer"]
ENCRYPT[EncryptionLOG["LoggedAction at Rest\nAES-256]
TRANSIT[Encryption in Transit\nTLS 1.3]
MASK[PII Masking\nLast-4 only for cards/bank accounts]
VAULT[Secrets Management\nAWS Secrets Managertable\nAPPEND-ONLY\nIP + JWT_SECRETuser env]+ timestamp\nold/new values (changedFields)"]
end
subgraph Monitoring["Security Monitoring"]
SENTRY[Error Monitoring\nSentry]
BETTERSTACK[Uptime + Log Monitoring\nBetterStack]
ALERTS[Security Alerts\nPagerDuty escalation]
AUDIT[Audit Trail\nSession table + structured logs]
end
DNSCLIENT --> WAFCF --> CDNHELMET --> LB
LB --> SG --> VPC
VPC --> BANKID --> AUTH --> AUTHZ --> VALID
VALIDCORS --> RATE RATE--> AUTH_MW --> Data
DataRBAC_MW --> MonitoringZOD --> PRISMA_ORM --> PG_ENC
PRISMA_ORM --> LOG
2. Authentication Flows
2.1 BankID 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: GenerateStrategy: JWT (HS256,JSON 24h)Web Tokens)
Why JWT:
- Stateless — scales horizontally on Railway
- Works with PWA/mobile
- Industry standard for multi-tenant SaaS
2.2 Token Types
Access Token
- Lifetime: 15 minutes
- Storage:
Authorization: Bearer <token> header (memory only — not localStorage)
- Contains: user ID (
sub), createorganization sessionID record(org), API-->>App:role
Set- Refresh: Automatic via refresh token rotation
Refresh Token
- Lifetime: 7 days
- Storage: httpOnly cookie (
JWT)not +accessible 200to OKJavaScript)
App->>App:- Rotation:
RenderNew dashboardrefresh
token issued on each use
2.23 CurrentJWT Email/PasswordAuth Login (MVP — pre-BankID)Flow
sequenceDiagram
autonumber
actor User
participant FE as DropFrontend Frontend(bilko.io)
participant API as DropExpress API (src/drop-app)api.bilko.io)
participant DB as SQLitePostgreSQL /(Railway PostgreSQLEU)
User->>FE: Enter email + password
FE->>API: POST /api/v1/auth/login {email, password}
API->>API: Rate limit check (10 req/60s per IP — middleware.ts)
API->>API: CSRF Origin header validation
API->>DB: SELECT user WHERE email = ? (parameterized)
DB-->>API: User record (passwordHash)
API->>API: bcrypt.compare(password, hash) — cost12 factor 12rounds
alt InvalidPassword credentialsvalid
API->>API: jwt.sign({sub, org, role}, JWT_SECRET, 15m)
API->>API: jwt.sign({sub}, JWT_REFRESH_SECRET, 7d)
API->>DB: INSERT refreshToken (hashed, expiresAt)
API-->>FE: 200 { accessToken } + Set-Cookie: refreshToken (httpOnly, secure, sameSite=strict)
FE->>FE: Store accessToken in memory only
else Password invalid
API-->>FE: 401 Unauthorized (generic message — Generic "Invalid credentials" (no user enumeration)
end
API->>DB: INSERT INTO sessions (id, user_id, token_hash, expires_at, revoked=0)
API->>API: Generate JWT HS256 — setIssuedAt(), 24h expiry
API-->>FE: Set httpOnly=true, secure=true, sameSite=strict cookie
FE->>FE: Render dashboard (no token in localStorage)
2.3 Token Refresh & Session Revocation
sequenceDiagram
autonumber
actor FE as Drop Frontend
participant API as Drop API
participant DB as Session Store
FE->>API: Any authenticated request (httpOnly cookie sent automatically)
API->>API: Verify JWT signature + expiry (jose library)
API->>DB: SELECT sessions WHERE token_hash = SHA256(jwt) AND revoked = 0
alt Session revoked or expired
API-->>FE: 401 — Force re-login
end
API->>API: Extract userId, proceed with request
Note over FE,DB:API: On15 logoutminutes later — allaccess sessionstoken revokedexpires
FE->>API: POST /api/v1/auth/refresh (Cookie: refreshToken)
API->>DB: SELECT refreshToken WHERE token = hash AND expiresAt > NOW()
DB-->>API: Valid token record
API->>API: Rotate: delete old, issue new refresh token
API-->>FE: 200 { newAccessToken } + Set-Cookie: newRefreshToken
Note over User,DB: Logout
User->>FE: Click logout
FE->>API: POST /api/v1/auth/logout
API->>DB: UPDATEDELETE sessions SET revoked=1refreshToken WHERE user_iduserId = ?
API-->>FE: 204 No Content
FE->>FE: Clear httpOnlyaccessToken cookiefrom memory
2.4 MFA — Dynamic Linking for PSD2 Compliance (Phase 2)
BankID inherently provides possession + knowledge (two factors). For PSD2 Strong CustomerTwo-Factor Authentication (SCA):2FA)
Method: TOTP (Time-based One-Time Password) — RFC 6238 Compatible apps: Google Authenticator, Authy, 1Password, Microsoft Authenticator
Setup:
POST /api/v1/auth/2fa/setup→ QR code + base32 secret- User scans QR code in authenticator app
POST /api/v1/auth/2fa/verify { code }→ 2FA enabled
Login with 2FA:
POST /api/v1/auth/login→{ requires2FA: true, tempToken }POST /api/v1/auth/2fa/login { tempToken, code }→ access + refresh tokens
Backup codes: 10 single-use codes generated during setup, stored bcrypt-hashed.
3. Authorization (RBAC)
3.1 Role Permission Matrix
| admin | accountant | viewer | ||
|---|---|---|---|---|
| Create invoice | ✅ | ✅ | ❌ | ❌ |
| Edit invoice | ✅ | ✅ | ❌ | ❌ |
| Delete invoice | ✅ | ❌ | ❌ | ❌ |
| View invoice | ✅ | ✅ | ✅ | ✅ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Generate report | ✅ | ✅ | ✅ | ❌ |
| Invite user | ✅ | ❌ | ❌ | ❌ |
| Edit org settings | ✅ | ❌ | ❌ | ❌ |
| Delete org | ✅ | ❌ | ❌ | ❌ |
3.2 Organization Scoping (IDOR Prevention)
Every data query is scoped to the authenticated user's organizationId, injected by middleware:
// Organization scope middleware — applied to all /api/v1/* routes
app.use('/api/v1/*', (req, res, next) => {
req.prismaWhere = { organizationId: req.user.organizationId };
next();
});
// Applied to every Prisma query — no direct object reference possible
await prisma.invoice.findMany({ where: { ...req.prismaWhere } });
UUID primary keys throughout — no sequential IDs that enable enumeration.
3.3 RBAC Middleware
function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.post('/api/v1/invoices', requireRole(['owner', 'admin']), createInvoice);
app.delete('/api/v1/invoices/:id', requireRole(['owner']), deleteInvoice);
4. Encryption
4.1 In Transit: TLS 1.3
All traffic via HTTPS:
- Frontend (Vercel): Automatic HTTPS, TLS 1.3
- Backend (Railway): Automatic HTTPS, TLS 1.3
- Cloudflare CDN: TLS 1.3 termination at edge, re-encrypted to origin
- HSTS:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
4.2 At Rest: AES-256
| Store | Method | Notes |
|---|---|---|
Current state (MVP): Email/password only — no SCA compliance. BankID integration is Phase 2.
3. Authorization Model
Model: RBAC (Role-Based Access Control) with mandatory user-scoped data isolation
3.1 Roles & Permissions Matrix
KYC Status Gates (required for financial operations):
| |||
| |||
|
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 hasrole = 'merchant'Merchant record belongs to authenticated user
3.3 Permission Hierarchy
admin (platform-level — full access)
└── merchant (tenant-level — own data + merchant dashboard)
└── user (standard — own data only)
4. Data Encryption
4.1 Encryption at Rest
| PostgreSQL |
AES- |
|
| AES- |
||
4.23 EncryptionPassword in TransitSecurity
TLSAlgorithm:configurationbcrypt,(Cloudflare12 salt rounds- Requirements: Min 8 chars, uppercase +
AWSlowercaseApp+Runner):digit - Common password list:
ssl_protocolsBlockTLSv1.3;topssl_ciphers10KTLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;knownStrict-Transport-Security:weakmax-age=63072000;passwords - History:
preloadPrevious 5 password hashes stored and blocked
4.4 Financial Data Precision
All APImonetary traffic:amounts HTTPSstored only (HTTP → 301 redirect). httpOnly cookies withas secure:NUMERIC(19,4)true— never float or JavaScript number. Exchange rates locked at transaction date. This prevents rounding errors in production.VAT and tax calculations.
4.3 Field-Level Encryption for PII
Fødselsnummer → AES-256-GCM, encrypted immediately on receipt from BankID
Key: NATIONAL_ID_KEY (AWS KMS — separate from database master key)
Stored: Only encrypted ciphertext 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 + WAF
→ Cloudflare CDN (TLS 1.3 termination at edge)
→ AWS App Runner (re-encrypts, TLS 1.3)
→ Application containers (Next.js)
→ Database (SQLite → PostgreSQL in private subnet)
5.2 Firewall Rules
5.3 WAF Rules (Cloudflare)
6. API Security
6.1 Rate Limiting Strategy
Source: src/drop-app/src/lib/middleware.ts:6-31
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 text inputs sanitized
sanitizeText(input) → removes HTML tags, control chars, trims, enforces maxLength
// Financial validation
validateAmount(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
// Identity validation
validateIBAN(iban) → format + checksum
validatePIN(pin) → exactly 4 digits
validateCurrency(currency) → whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
validateLanguage(lang) → whitelist: nb, en, bs, sq
// SQL: ALL 24 endpoints use parameterized queries — zero string concatenation
6.3 CORS Policy
// Production CORS config
{
origin: ['https://getdrop.no', 'https://app.getdrop.no'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Required for httpOnly cookies
maxAge: 86400
}
6.4 Content Security Policy
Source: src/drop-app/next.config.ts:6-46
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
frame-ancestors 'none';
[remaining headers...]
Known issue (Medium M1): unsafe-inline and unsafe-eval required for Next.js. Production target: nonce-based CSP.
7. OWASP Top 10 Mitigation MatrixMitigations
| OWASP Risk | Mitigation | Status | |
|---|---|---|---|
| A01: Broken Access Control | RBAC + |
| |
| A02: Cryptographic Failures | TLS 1.3 + AES-256 at rest + bcrypt(12) + no PII in JWTs | ||
| A03: Injection | |||
| A04: Insecure Design | |||
| A05: Security Misconfiguration | *), sanitized error messages |
||
| A06: Vulnerable Components | npm audit + lock file committed |
||
| A07: Auth Failures | Rate limiting (5/15min auth) + |
||
| A08: Software Integrity | Signed commits + CI/CD pipeline + Dependabot | ||
| A09: Logging Failures | |||
| A10: SSRF | Input validation |
6. Rate Limiting
| Endpoint | Limit | Window |
|---|---|---|
POST /api/v1/auth/login |
5 requests | 15 minutes |
POST /api/v1/auth/register |
3 requests | 60 minutes |
POST /api/v1/auth/refresh |
10 requests | 15 minutes |
GET /api/v1/reports/* |
10 requests | 15 minutes |
All other /api/v1/* |
100 requests | 15 minutes |
Implementation: express-rate-limit, per-IP tracking, 429 Too Many Requests response.
7. Input Validation
All inputs validated with Zod schemas before reaching business logic:
const createInvoiceSchema = z.object({
customerId: z.string().uuid(),
invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
currencyCode: z.enum(['EUR', 'RSD', 'BAM']),
items: z.array(z.object({
description: z.string().min(1).max(500),
quantity: z.number().positive(),
unitPrice: z.number().nonnegative(),
taxRate: z.number().min(0).max(100),
})),
});
8. File Upload Security
- Allowed types: JPG, PNG, PDF (receipts, invoice attachments)
- Max size: 10 MB per file
- Validation: MIME type + extension check
- Storage: Cloudflare R2 (EU region), not served from app server
- Planned (Phase 2): ClamAV virus scanning before R2 upload
9. Audit Trail
LoggedAction Table (Immutable — APPEND-ONLY)
Every mutation logged:
tableName— which table was affectedaction— INSERT / UPDATE / DELETEuserId— who performed the actionactionTimestamp— UTC timestamprowData— full row snapshot (before state)changedFields—{ field: { old: X, new: Y } }clientIp— requester IP address
Never delete from LoggedAction. On user data erasure (GDPR Art. 17), user ID is anonymized to "deleted-user" — the log entries themselves are retained for financial compliance.
10. Security Headers Checklist(Helmet.js)
Source: src/drop-app/next.config.ts
| Header | Value | |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
|
Content-Security-Policy |
default-src 'self'; | |
X-Content-Type-Options |
nosniff |
|
X-Frame-Options |
DENY |
|
X-Powered-By |
Removed | |
Referrer-Policy |
strict-origin-when-cross-origin |
|
| | |
| | |
| |
9.11. DependencySecurity VulnerabilityPre-Launch ManagementChecklist
Current dependency status (audit date: 2026-02-12):
| |||
| |||
| |||
| |||
| |||
|
Scanning tools (planned):
SAST:SemgrepJWT_SECRET/ CodeQLgenerated (runs32+ chars, CSPRNG)- JWT_REFRESH_SECRET generated (32+ chars, separate from JWT_SECRET)
- HTTPS enforced (no HTTP)
- CORS whitelist configured (only bilko.io)
- Rate limiting enabled and tested
- Helmet.js headers verified
- bcrypt rounds = 12
- All Prisma queries use org-scope WHERE
- Input validation on
everyallPR)endpoints (Zod) SCA:File upload restrictions in place- LoggedAction audit trail active
- Error responses sanitized (no stack traces)
- Dependabot
+alertsnpmenabled -
(runsPostgreSQL oneveryRailwayPREU+Westdaily on main)region Container:TrivyDPAs signed (runsRailway,onVercel,DockerCloudflare,imageSendGrid)- Data deletion workflow tested
Remediation SLAs:
10.Related Security Logging & Audit TrailDocuments
Current monitoring stack:
Sentry:ComplianceErrorFramework:tracking, exception monitoring, performancecompliance-framework.mdBetterStack:DataUptimeEncryptionmonitoring,Policy:log aggregation, alertingdata-encryption-policy.mdSessionsKeytable:ManagementTokenPolicy:revocation,key-management-policy.md- DPIA:
lifecycledata-protection-impact-assessment.md - Breach Response: data-breach-response-plan.md
- Security Testing: security-testing-policy.md
- Bilko Security Architecture: ../../products/Bilko/docs/security/SECURITY-ARCHITECTURE.md
Planned (pre-production):
Log retention (hvitvaskingsloven § 30): Minimum 5 years for KYC and transaction data.
SIEM integration (Phase 2): BetterStack → Sentry → PagerDuty escalation chain.
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | 2026-02-23 | ||
| DPO | |||