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 — |
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)HR via AZOP) | Zakon o računovodstvu RSRS/HR/BA | Zakon o PDV BiH | Zakon o PDV RS/BA/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 scopedisolated 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)EU West)\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/mobileNext.js frontend and PWA - Industry standard for multi-tenant SaaS
2.2 Token Types
| Token | Lifetime | Storage | Contains |
|---|---|---|---|
| Access |
15 minutes | Authorization: Bearer |
userId, |
| Refresh token | 7 days | httpOnly cookie ( |
userId |
Refresh tokens stored as HMAC-SHA-256 hash in database. Raw token issuednever stored. Rotation on eachevery use
2.3 JWT Auth Flow
sequenceDiagram
actor User
participant FE as Frontend (bilko.io)io — Vercel)
participant API as Express API (api.bilko.io)io — Railway EU)
participant DB as PostgreSQL (Railway EU)EU West)
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)secure)
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 tokenhash = hashhash(token) 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-basedRFC One-Time Password)6238) — RFC 6238
Compatible apps: Google Authenticator, Authy, 1Password, Microsoft Authenticator
Setup:
- Setup:
POST /api/v1/auth/2fa/setup→ QR code + base32 secret UserVerify:scans QR code in authenticator appPOST /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
codes generated during setup, storedcodes, 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 byvia middleware:
// OrganizationInjected scopeby auth middleware — applied toon 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 enableenabling 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:Cloudflare: 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 | |
|---|---|---|
| PostgreSQL |
AES-256 disk encryption (Railway |
|
| PostgreSQL backups | AES-256 (Railway automatic) | Railway EU West — 30-day |
| Tax IDs, IBAN | AES-256-GCM field encryption (application layer) | Railway EU West |
| Cloudflare R2 ( |
AES-256 server-side |
4.3 Password Security
Algorithm:bcrypt, 12 saltroundsRequirements:rounds. Min 8chars, uppercase + lowercase + digitCommon password list:chars. Block top 10Kknowncommonweakpasswords.passwordsRetain History:Previouslast 5password hashes stored and blockedhashes.
4.4 Financial Data Precision
All monetary amounts stored asamounts: NUMERIC(19,4) — never float or JavaScript number.float. 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 |
Designed |
| A02: Cryptographic Failures | TLS 1.3 + AES-256 |
Designed |
| A03: Injection | Prisma ORM parameterized queries exclusively |
Designed |
| A04: Insecure Design | Multi-tenant |
Designed |
| A05: Security Misconfiguration | Helmet.*), sanitized |
Designed |
| A06: Vulnerable Components | Dependabot npm audit + lock file committed |
Planned |
| A07: Auth Failures | Rate limiting |
Designed |
| A08: Software Integrity | Signed commits + CI/CD |
Planned |
| A09: Logging Failures | Immutable LoggedAction |
Designed |
| A10: SSRF | Designed |
6. Rate Limiting
| Endpoint | Limit | Window |
|---|---|---|
POST /api/v1/auth/login |
5 |
15 |
POST /api/v1/auth/register |
3 |
60 |
POST /api/v1/auth/refresh |
10 |
15 |
GET /api/v1/reports/* |
10 |
15 |
All other /api/v1/* |
100 |
15 |
Implementation: express-rate-limit, per-IP tracking,IP, 429 Too Many Requests response..
7. Input Validation
All inputs validated with Zod schemas before reaching business logic:schemas:
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:Allowed: JPG, PNG, PDF (receipts, invoice attachments). Max 10 MB.Max size:10 MB per fileValidation:MIME type + extensioncheckvalidation. Storage:Stored in Cloudflare R2 (EU region), not served from app server.- Planned
(Phase2):2: ClamAV virus scanning before R2uploadupload.
9. Audit Trail
LoggedAction Table (Immutable — APPEND-ONLY)
Every
| Field | Type | Description |
|---|---|---|
| eventId | BIGSERIAL | Auto-incrementing |
| tableName | TEXT | Which table was |
| action | TEXT | INSERT / UPDATE / DELETE |
| userId | UUID | Who performed the action |
| actionTimestamp | TIMESTAMPTZ | UTC timestamp |
| rowData | JSONB | Full row snapshot |
| changedFields | JSONB | { field: { old: X, new: Y } } |
| clientIp | INET | Requester IP |
Never delete from LoggedAction. On user data erasure (GDPR Art.erasure: 17), user ID isuserId anonymized to "deleted-user". — theFinancial log entries themselves are retained for financiallegal compliance.compliance period (10-11 years).
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 Security Checklist
- JWT_SECRET generated (32+ chars, CSPRNG) — Railway env secret
- JWT_REFRESH_SECRET generated (separate key, 32+
chars,chars) -
fromFIELD_ENCRYPTION_KEYJWT_SECRET)generated (32 bytes hex) — for tax IDs and IBAN - HTTPS enforced
(— noHTTP)HTTP - CORS
whitelist configured (onlywhitelist: bilko.io)io only - Rate limiting
enabled andtested - Helmet.js headers verified
- bcrypt rounds = 12
- All Prisma queries use org-
scopescoped WHERE -
InputZod validation on all endpoints(Zod) - File upload restrictions
in placeactive - LoggedAction
audit trailtrigger active on all tables - Error responses sanitized
(— no stacktraces)traces - Dependabot alerts enabled
-
PostgreSQLRailwayonregionRailway= EU Westregionconfirmed - DPAs signed (Railway, Vercel, Cloudflare, SendGrid)
- Data deletion workflow tested end-to-end
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 |