Security Architecture
Security Architecture Document
Project: Bilko — Balkan Accounting SaaS Version: 1.0 Date: 2026-02-23 Author: Compliance Architect Status: Draft Reviewers: CTO, DPO, Engineering Lead Classification: Confidential
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | Compliance Architect | Initial draft — based on Bilko security architecture spec |
1. Security Architecture Overview
Security Owner: Compliance Architect ([email protected]) Last Security Review: 2026-02-23 Next Scheduled Review: 2026-08-23 Compliance Targets: GDPR | Zakon o zaštiti podataka o ličnosti RS (ZZPL) | Zakon o zaštiti ličnih podataka BiH (ZZLP) | GDPR (HR) | Zakon o računovodstvu RS | Zakon o PDV BiH | Zakon o PDV HR
Architecture Model: Bilko is a multi-tenant cloud accounting SaaS. It processes invoices, expenses, VAT returns, and financial reports on behalf of organizations in Serbia, Bosnia & Herzegovina, and Croatia. Each organization's data is strictly scoped by organizationId at the database layer.
Defense-in-Depth Overview
graph TD
CLIENT["Client Browser / PWA"]
subgraph NETWORK["Network Layer"]
CF["Cloudflare WAF\nDDoS Protection\nTLS 1.3 termination\nHSTS"]
end
subgraph APP_LAYER["Application Layer"]
HELMET["Helmet.js\nCSP + X-Frame + HSTS\nno X-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 / admin / accountant / viewer"]
ZOD["Zod Validation\nall request bodies\ntype-safe parsing"]
end
subgraph DATA_LAYER["Data Layer"]
PRISMA_ORM["Prisma ORM\nparameterized queries\nno raw SQL for user input\norg-scoped WHERE clauses"]
PG_ENC["PostgreSQL (Railway EU)\nAES-256 disk encryption\nbackup encryption"]
end
subgraph AUDIT["Audit Layer"]
LOG["LoggedAction table\nAPPEND-ONLY\nIP + user + timestamp\nold/new values (changedFields)"]
end
CLIENT --> CF --> HELMET --> CORS --> RATE --> AUTH_MW --> RBAC_MW --> ZOD --> PRISMA_ORM --> PG_ENC
PRISMA_ORM --> LOG
2. Authentication
2.1 Strategy: JWT (JSON 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), organization ID (org), role - Refresh: Automatic via refresh token rotation
Refresh Token
- Lifetime: 7 days
- Storage: httpOnly cookie (not accessible to JavaScript)
- Rotation: New refresh token issued on each use
- Revocation: Stored hashed in database, invalidated on logout
2.3 JWT Auth Flow
sequenceDiagram
actor User
participant FE as Frontend (bilko.io)
participant API as Express API (api.bilko.io)
participant DB as PostgreSQL (Railway EU)
User->>FE: Enter email + password
FE->>API: POST /api/v1/auth/login
API->>DB: SELECT user WHERE email = ? (parameterized)
DB-->>API: User record (passwordHash)
API->>API: bcrypt.compare(password, hash) — 12 rounds
alt Password valid
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 — no user enumeration)
end
Note over FE,API: 15 minutes later — access token expires
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: DELETE refreshToken WHERE userId = ?
API-->>FE: 204 No Content
FE->>FE: Clear accessToken from memory
2.4 Two-Factor Authentication (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
| Action | owner | 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 |
|---|---|---|
| PostgreSQL (Railway EU West) | AES-256 disk encryption (Railway default) | Frankfurt or Paris region — EU data residency |
| PostgreSQL backups | AES-256 (Railway automatic) | 30-day rolling backup retention |
| Cloudflare R2 (file storage) | AES-256 server-side | Receipts, invoice PDFs |
4.3 Password Security
- Algorithm: bcrypt, 12 salt rounds
- Requirements: Min 8 chars, uppercase + lowercase + digit
- Common password list: Block top 10K known weak passwords
- History: Previous 5 password hashes stored and blocked
4.4 Financial Data Precision
All monetary amounts stored as NUMERIC(19,4) — never float or JavaScript number. Exchange rates locked at transaction date. This prevents rounding errors in VAT and tax calculations.
5. OWASP Top 10 Mitigations
| OWASP Risk | Mitigation | Status |
|---|---|---|
| A01: Broken Access Control | RBAC + org-scoped WHERE on every query + UUID PKs | Designed |
| A02: Cryptographic Failures | TLS 1.3 + AES-256 at rest + bcrypt(12) + no PII in JWTs | Designed |
| A03: Injection | Prisma ORM parameterized queries exclusively — zero raw SQL for user input | Designed |
| A04: Insecure Design | Multi-tenant scoping at DB layer, immutable audit trail | Designed |
| A05: Security Misconfiguration | Helmet.js headers, CORS whitelist (no *), sanitized error messages |
Designed |
| A06: Vulnerable Components | Dependabot alerts + weekly npm audit + lock file committed |
Planned |
| A07: Auth Failures | Rate limiting (5/15min auth) + JWT rotation + 2FA + bcrypt | Designed |
| A08: Software Integrity | Signed commits + CI/CD pipeline + Dependabot | Planned |
| A09: Logging Failures | Immutable LoggedAction audit trail + Railway logs + Sentry | Designed |
| A10: SSRF | Input validation (Zod schemas), allowlist for external API calls (SEF, eRačun) | Designed |
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 (Helmet.js)
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Content-Security-Policy |
default-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https: |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-Powered-By |
Removed |
Referrer-Policy |
strict-origin-when-cross-origin |
11. Security Pre-Launch Checklist
- JWT_SECRET generated (32+ 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 all endpoints (Zod)
- File upload restrictions in place
- LoggedAction audit trail active
- Error responses sanitized (no stack traces)
- Dependabot alerts enabled
- PostgreSQL on Railway EU West region
- DPAs signed (Railway, Vercel, Cloudflare, SendGrid)
- Data deletion workflow tested
Related Documents
- Compliance Framework: compliance-framework.md
- Data Encryption Policy: data-encryption-policy.md
- Key Management Policy: key-management-policy.md
- DPIA: data-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
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Compliance Architect | 2026-02-23 | |
| CTO | |||
| DPO | |||
| Engineering Lead |