Security Architecture
Bilko — Security Architecture Document
Project: Bilko
— Balkan Accounting SaaSVersion: 1.0 Date: 2026-02-2325 Author:ComplianceALAIArchitectDocumentation Team Status:DraftFinalReviewers:Note:CTO,ArchitectureDPO,isEngineeringdocumentedLeadforClassification:implementation.ConfidentialBackend implementation is in progress (Phase 2).
This document defines the security architecture for Bilko, a financial SaaS handling sensitive accounting data.
DocumentSecurity HistoryPrinciples
- Defense in Depth — Multiple layers of security (network, application, database)
- Least Privilege — Users and services get minimum necessary permissions
- Zero Trust — Verify every request, never assume trust
- Encryption Everywhere — Data encrypted in transit and at rest
- Immutable Audit Trail — All actions logged, tamper-proof
STRIDE Threat Model
Bilko handles sensitive financial data (tax IDs, IBAN, accounting records) across three jurisdictions. The STRIDE model identifies threats specific to each layer.
Spoofing — Identity Threats
| Session hijacking | Refresh token cookie stolen via network or MITM | HIGH — 7-day session takeover | httpOnly + Secure + SameSite=Strict cookie; TLS 1.3 only |
| Credential stuffing | Automated login with leaked credentials | HIGH — financial platform targeted | Rate limiting (5 req/min); bcrypt 12 rounds; HIBP breach check |
| JWT algorithm confusion | Attacker sends alg: none or switches HS256/RS256 |
MEDIUM | jsonwebtoken always specifies algorithm explicitly; RS256 enforced |
| Account enumeration | Timing attack on login endpoint reveals valid emails | MEDIUM | Constant-time response regardless of email existence |
Tampering — Data Integrity Threats
| Threat | Attack Vector | Bilko Risk | Mitigation |
|---|---|---|---|
| Invoice amount modification | MITM attack modifies invoice amounts in transit | HIGH — financial fraud | TLS 1.3 for all connections; HSTS with preload |
| Transaction record alteration | Unauthorized user modifies financial records | CRITICAL — accounting integrity | LoggedAction audit trail (append-only); Prisma soft delete only |
| JWT payload manipulation | Attacker decodes JWT, changes role: viewer to role: owner |
HIGH — privilege escalation | RS256 signature verification; any modification invalidates signature |
| Database record tampering | Direct DB access bypasses application | HIGH — data integrity loss | Railway access restricted to CTO only; no public DB port |
| File upload replacement | Upload modified invoice PDF with different amounts | MEDIUM | File stored by hash; original uploaded by authorized user; audit trail |
Repudiation — Non-Traceability Threats
| Threat | Attack Vector | Bilko Risk | Mitigation |
|---|---|---|---|
| Audit log bypass | Attacker finds code path that skips LoggedAction | HIGH — undetected fraud | Prisma middleware applies audit to ALL model mutations; test coverage |
| LoggedAction deletion | Admin or attacker deletes audit records | CRITICAL — compliance violation | LoggedAction has no DELETE permission in RBAC; DB-level row security |
| Timestamp manipulation | System clock skewed to invalidate audit timestamps | LOW | Railway NTP; JWT iat verified server-side |
| User denies action | "I never deleted that invoice" | MEDIUM | Audit log captures: userId, IP, exact timestamp, old values, new values |
Information Disclosure — Data Leakage Threats
| Threat | Attack Vector | Bilko Risk | Mitigation |
|---|---|---|---|
| Cross-tenant data leak | Missing organizationId WHERE clause on Prisma query |
CRITICAL — GDPR breach | Org-scoping middleware on all routes; lint rule + automated isolation tests |
| Financial data in API errors | Stack trace contains query with financial amounts | HIGH | Production error handler returns only generic message + error ID |
| Tax ID (JMBG/OIB) exposure | DB breach exposes plaintext personal citizen IDs | CRITICAL — identity theft, irrevocable | AES-256-GCM field-level encryption (Tier 1) via prisma-field-encryption (See ADR-014) |
| Tax ID (PIB/JIB) exposure | DB breach exposes business tax IDs | LOW — publicly available on APR/UIO portals | Disk-level encryption (Railway AES-256) + org-scoping + RBAC (Tier 2, See ADR-014) |
| IBAN exposure | DB breach or API response over-returning | MEDIUM — routinely shared for payment | Disk-level encryption + IBAN masked in list responses (last 4 digits only) (See ADR-014) |
| JWT contains PII | Access token readable by any party | MEDIUM | JWT contains only user ID, org ID, role — no email, name, or financial data |
| Log file leakage | Application logs contain email addresses or amounts | MEDIUM | Logging policy: never log request body for financial endpoints |
Denial of Service — Availability Threats
| Threat | Attack Vector | Bilko Risk | Mitigation |
|---|---|---|---|
| Authentication flooding | Brute force login with millions of requests | HIGH | Rate limiting: 5 requests/15min on auth endpoints; Cloudflare DDoS protection |
| Report generation abuse | Repeated complex report requests exhaust DB | MEDIUM | Rate limiting: 10 requests/15min on /api/v1/reports/*; caching layer planned |
| File upload flooding | Upload large files repeatedly | MEDIUM | 10MB limit; multer request counting; Cloudflare rate limiting at edge |
| Database connection exhaustion | Many concurrent requests exceed pool size | MEDIUM | Prisma connection pool limits; Railway auto-scaling |
| Webhook replay flooding | Repeat webhook calls to SEF/FINA integration | LOW | Idempotency keys on e-invoice submissions; webhook signature verification |
Elevation of Privilege — Access Control Threats
| Threat | Attack Vector | Bilko Risk | Mitigation |
|---|---|---|---|
| RBAC bypass via role tampering | Modify JWT role claim to gain admin access | CRITICAL | RS256 signature; role read from verified JWT payload only |
| Cross-tenant elevation | Org-1 user accesses Org-2 resources by guessing UUID | HIGH — multi-tenant SaaS | UUID v4 unpredictable; org-scoped WHERE mandatory; 404 (not 403) on cross-org requests |
| Horizontal privilege escalation | Accountant accesses another user's profile in same org | MEDIUM | Per-user data scoped by userId; endpoints check req.user.id === resource.userId |
| API endpoint enumeration | Attacker discovers undocumented admin endpoints | LOW | No hidden admin endpoints; all endpoints in API spec; Cloudflare WAF |
| Dependency hijacking | Malicious package injected via supply chain | MEDIUM | package-lock.json committed; Dependabot; npm audit in CI |
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 via AZOP (HR) | Zakon o računovodstvu RS/HR/BA | Zakon o PDV RS/BA/HR
Architecture Model: Bilko is a multi-tenant cloud accounting SaaS. Processes invoices, expenses, VAT returns, and financial reports for organizations in Serbia, Bosnia & Herzegovina, and Croatia. Each organization's data strictly isolated 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 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
JWT,(scaleshorizontallyhorizontally) - Works with mobile PWA
- Industry standard
Token Types
Access Token
- Lifetime: 15 minutes
- Storage:
Authorization: Bearer <token>header - Contains: User ID, organization ID, role
- Refresh: Automatic via refresh token
Refresh Token
- Lifetime: 7 days
- Storage: httpOnly cookie (not accessible to JavaScript)
- Purpose: Obtain new access token
- Rotation: New refresh token issued on
Railway.eachAccessrefresh - Revocation: Stored in database, can be invalidated
JWT Payload Example
{
"sub": "user-uuid",
"org": "org-uuid",
"role": "admin",
"iat": 1640000000,
"exp": 1640000900,
"jti": "unique-token-id"
}
jti(15JWTmin,ID)memory-only)—+ refresh tokens (7 days, httpOnly cookie). Rotation on every refresh. Revocation via hashedunique tokenstorageidentifierinusedDB.to prevent replay attacks and enable server-side token invalidation.
2.2 JWT AuthToken Flow
sequenceDiagram
actor1. User participantlogs FEin as Frontend (bilko.io — Vercel)
participant API as Express API (api.bilko.io — Railway EU)
participant DB as PostgreSQL (Railway EU West)
User->>FE: Enter email + password
FE->>API:→ POST /api/v1/auth/login
API->>DB:← SELECTAccess user WHERE email = ?token (parameterized)header) DB-->>API:+ Refresh token (httpOnly cookie)
2. User recordmakes request → GET /api/v1/invoices (passwordHash)Authorization: API-Bearer <access>>API:)
bcrypt.compare(password,← hash)Protected —resource
123. rounds
alt Password valid
API->>API: jwt.sign({sub, org, role}, JWT_SECRET, 15m)
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 — no user enumeration)
end
Note over FE,API: 15 minutes later — accessAccess token expires FE->>API:(15 min) → POST /api/v1/auth/refresh (Cookie:httpOnly refreshToken)cookie)
API->>API:← Rotate:New deleteaccess old, issue new
API-->>FE: 200 { newAccessToken }token + Set-Cookie:New newRefreshTokenrefresh Notetoken
over4. User,DB:User Logoutlogs FE->>API:out → POST /api/v1/auth/logout
API->>DB:→ DELETEDelete refreshTokenrefresh WHEREtoken userIdfrom =DB
?
API-->>FE:← 204 No Content
2.3Implementation (Backend)
import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
// Generate access token
const accessToken = jwt.sign(
{ sub: user.id, org: user.organizationId, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '15m' },
)
// Generate refresh token
const refreshToken = jwt.sign({ sub: user.id }, process.env.JWT_REFRESH_SECRET!, {
expiresIn: '7d',
})
// Store refresh token in DB (for revocation)
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
})
Token Invalidation Events
Refresh tokens must be revoked server-side on any of these events:
- User logout
- Password change
- Role change by admin
- Account suspension
- Suspicious login from unknown IP/country
Password Security
Hashing: bcrypt
Algorithm: bcrypt with 12 salt rounds
Why bcrypt?
- Designed for passwords (slow by design, resists brute force)
- Auto-salted (each password has unique salt)
- Adaptive (can increase rounds as hardware improves)
Password Requirements
- Minimum length: 8 characters
- Complexity: At least one uppercase, one lowercase, one number
- No common passwords: Check against list of 10K most common passwords
- No reuse: Previous 5 passwords stored (hashed) and blocked
Implementation
import bcrypt from 'bcrypt'
// Hash password (registration)
const passwordHash = await bcrypt.hash(password, 12)
// Verify password (login)
const isValid = await bcrypt.compare(password, user.passwordHash)
Two-Factor Authentication (2FA)
Strategy: TOTP (Time-based One-Time Password)
Method:Compatible with: TOTP (RFC 6238) — Google Authenticator, Authy, 1Password
Setup:Google Authenticator- Authy
- 1Password
- Microsoft Authenticator
Setup Flow
1. User enables 2FA → POST /api/v1/auth/2fa/setup→← QR code +base32secretsecret(base32)
User scans QR code in authenticator app
→ Generates 6-digit code
3. User verifies code → POST /api/v1/auth/2fa/verify { code }
← 200 OK (2FA enabled)
Login returnsFlow with 2FA
1. User logs in → POST /api/v1/auth/login { email, password } ← 200 OK + { requires2FA: true, tempToken }2. User enters code →POST /api/v1/auth/2fa/login { tempToken, code } ← Access token + Refresh token
Backup Codes
Generate 10 single-use codes,backup bcrypt-codes during 2FA setup:
- Stored hashed (bcrypt)
- Used when authenticator unavailable
- Marked as used after redemption
3. Authorization (RBAC)
3.1Roles
| Role | Permissions |
|---|---|
| owner | Full access (edit org settings, invite users, delete data) |
| admin | Manage invoices, expenses, contacts, reports (no org settings) |
| accountant | Read invoices/expenses, create reports (no edit) |
| viewer | Read-only access (dashboard, reports) |
Permission Matrix
| Action | owner | admin | accountant | viewer |
|---|---|---|---|---|
| Create invoice | ✅ | ✅ | ❌ | ❌ |
| Edit invoice | ✅ | ✅ | ❌ | ❌ |
| Delete invoice | ✅ | ❌ | ❌ | ❌ |
| View invoice | ✅ | ✅ | ✅ | ✅ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Generate report | ✅ | ✅ | ✅ | ❌ |
| Invite user | ✅ | ❌ | ❌ | ❌ |
| Edit org settings | ✅ | ❌ | ❌ | ❌ |
3.2Implementation (Middleware)
import { Request, Response, NextFunction } from 'express'
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()
}
}
// Usage
app.post('/api/v1/invoices', requireRole(['owner', 'admin']), createInvoice)
Data Classification
| Level | Label | Examples | Controls |
|---|---|---|---|
| L4-A | Restricted (Personal) | JMBG, OIB | AES-256-GCM field-level encryption (prisma-field-encryption) + HMAC-SHA256 hash columns + access log (See ADR-014) |
| L4-B | Restricted (Business/Financial) | PIB, JIB, IBAN | Disk-level encryption (Railway AES-256) + TLS 1.3 + org-scoping + RBAC + API masking for IBAN (last 4 digits) (See ADR-014) |
| L3 | Confidential | Financial amounts, bank statements, invoices | Org-scoped access, TLS, PostgreSQL AES-256 at rest |
| L2 | Internal | Email, name, address, phone | TLS, authenticated access only |
| L1 | Public | Organization |
No special controls |
L4 Restricted fields use a hybrid encryption approach per ADR-014: personal identifiers (IDORJMBG, Prevention)OIB) receive AES-256-GCM field-level encryption before persistence because they are irrevocable and high-impact on breach. Business tax IDs (PIB, JIB) and IBAN rely on disk-level encryption plus application-layer controls — field-level encryption for publicly available identifiers would be disproportionate to the risk per GDPR Article 32.
Encryption
In Transit: TLS 1.3
All traffic encrypted via HTTPS:
- Frontend (Vercel): Automatic HTTPS
- Backend (Railway): Automatic HTTPS
- Certificate: Let's Encrypt (auto-renewed)
TLS Configuration:
- Minimum version: TLS 1.3
- Cipher suites: Modern only (no legacy ciphers)
- HSTS enabled (Strict-Transport-Security header)
At Rest: Database Encryption
PostgreSQL (Railway):
- Disk encryption: AES-256 (Railway default)
- Backup encryption: AES-256
- Column-level encryption: Hybrid approach per ADR-014 — JMBG and OIB fields use AES-256-GCM field-level encryption via
prisma-field-encryption(Tier 1). PIB, JIB, and IBAN rely on disk-level encryption + application controls (Tier 2). Disk-level encryption alone is insufficient for personal identifiers (JMBG/OIB) due to their irrevocability and high breach impact. (See ADR-014)
Cloudflare R2 (Files):
- Server-side encryption: AES-256 (default)
- No client-side encryption needed (files are receipts/invoices, not PII)
Secrets Management
NEVER commit secrets to git:
.envfiles in.gitignore- Use platform-provided secrets (Vercel, Railway)
- Rotate JWT secrets quarterly
- Rotate API keys annually
OWASP Top 10 Mitigations
1. Injection (SQL Injection)
Mitigation: Prisma ORM parameterized queries
// InjectedSAFE by— Prisma auto-escapes
await prisma.invoice.findMany({
where: { customerId: req.params.id },
})
// UNSAFE — Never use raw SQL for user input
await prisma.$queryRaw`SELECT * FROM invoices WHERE customer_id = ${req.params.id}`
2. Broken Authentication
Mitigations:
- bcrypt password hashing (12 rounds)
- JWT with short expiry (15 min)
- Refresh token rotation
- 2FA (TOTP)
- Rate limiting on auth
middlewareendpoints (5 req/min)
3. Sensitive Data Exposure
Mitigations:
- TLS 1.3 in transit
- AES-256 at rest
- No PII in JWTs (only user ID)
- No passwords in logs
- No sensitive data in URLs (use POST body)
4. XML External Entities (XXE)
Not applicable — Bilko does not parse XML.
5. Broken Access Control
Mitigations:
- RBAC enforced on
allevery endpoint - Organization-scoped queries (middleware)
- No direct object reference (use UUIDs, not auto-increment IDs)
/api/v1/*/ routesOrganization scoping middleware
app.use('/api/v1/*', (req, res, next) => {
req.prismaWhere = { organizationId: req.user.organizationId };
next();
});
// AppliedApply to every Prisma queryqueries
await prisma.invoice.findMany({ where: { ...req.prismaWhere } });
6. Security Misconfiguration
UUIDMitigations:
- Helmet.js
throughoutsecurity—headers - CORS whitelist (no
sequential*IDinenumerationproduction) - Error
messages
(no4.sanitizedEncryption4.1stackIntracesTransit:inTLSproduction) - Disable
X-Powered-Byheader
Full Security Headers Configuration
All trafficsecurity HTTPS.headers Cloudflareapplied TLSvia 1.3Helmet.js aton edge,the re-encryptedExpress toAPI. Railway.The HSTS:Next.js frontend applies equivalent headers via .max-age=63072000; includeSubDomains; preloadnext.config.js
4.2import Athelmet Rest:from AES-256
'helmet'
// Express API — full security headers
app.use(
helmet({
// Content-Security-Policy — prevent XSS
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No unsafe-inline needed on API
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
frameSrc: ["'none'"], // No iframes from this API
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
useDefaults: false,
},
// Strict-Transport-Security — force HTTPS for 1 year, include subdomains
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true, // Eligible for browser HSTS preload list
},
// X-Frame-Options — prevent clickjacking
frameguard: {
action: 'deny', // DENY: no framing at all
},
// X-Content-Type-Options — prevent MIME sniffing
noSniff: true,
// X-XSS-Protection — legacy header for older browsers
xssFilter: true,
// Referrer-Policy — don't leak URL in Referer header
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
// Permissions-Policy — disable browser features not needed by Bilko
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
// X-DNS-Prefetch-Control
dnsPrefetchControl: { allow: false },
// X-Powered-By removed by default in Helmet
hidePoweredBy: true,
}),
)
// Permissions-Policy header (not yet in Helmet — set manually)
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
)
next()
})
// CORS — whitelist only known origins
app.use(
cors({
origin: (origin, callback) => {
const allowed = [
'https://bilko.io',
'https://www.bilko.io',
'https://app.bilko.io',
'https://staging.bilko.io',
'https://bilko.rs', // Serbia redirect domain
]
if (!origin || allowed.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`CORS: origin ${origin} not allowed`))
}
},
credentials: true, // Required for httpOnly cookie (refresh token)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}),
)
import Athelmet Rest:from AES-256Next.js Frontend Security Headers (next.config.js)
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires these
"style-src 'self' 'unsafe-inline'", // Tailwind requires unsafe-inline
"img-src 'self' data: https:",
"connect-src 'self' https://api.bilko.io wss://api.bilko.io",
"font-src 'self' https://fonts.gstatic.com",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; '),
},
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
{ key: 'X-DNS-Prefetch-Control', value: 'off' },
]
module.exports = {
headers: async () => [{ source: '/:path*', headers: securityHeaders }],
}
Headers Verification
Use securityheaders.com to verify. Target grade: A+.
Content-Security-Policy |
||
Strict-Transport-Security |
max-age=31536000; |
|
X-Frame-Options |
DENY |
|
X-Content-Type-Options |
nosniff |
4.3 Password Security
bcrypt, 12 salt rounds. Min 8 chars. Block top 10K common passwords. Last 5 hashes retained.
4.4 Financial Data Precision
All monetary amounts: NUMERIC(19,4) — never float. Exchange rates locked at transaction date.
5. OWASP Top 10 Mitigations
Referrer-Policy |
strict-origin-when-cross-origin |
|
Permissions-Policy |
||
7. Cross-Site Scripting (XSS)
Mitigations:
- React auto-escapes output (default safe)
- CSP headers (Content-Security-Policy)
- Sanitize user input (Zod validation)
- No
dangerouslySetInnerHTMLwithout sanitization
// SAFE — React escapes by default
<p>{invoice.description}</p>
// UNSAFE — Only use with sanitized HTML
<div dangerouslySetInnerHTML={{ __html: sanitizedHTML }} />
8. Insecure Deserialization
Not applicable — Bilko does not deserialize untrusted data.
9. Using Components with Known Vulnerabilities
Mitigations:
- Dependabot alerts enabled (GitHub)
- Weekly
npm auditchecks - Automated security updates (Dependabot PRs)
- Lock file committed (
package-lock.json)
10. Insufficient Logging & Monitoring
Mitigations:
- Audit trail (LoggedAction table)
- Error tracking (Sentry recommended)
- Access logs (Railway built-in)
- Failed login attempts logged
- Anomaly detection (future: alert on 10+ failed logins)
6. Rate Limiting
Prevent brute force and abuse:
| Endpoint | Limit | Window | Status | Rationale |
|---|---|---|---|---|
/api/v1/auth/login, /register |
5 |
Implemented (authLimiter) |
Prevent credential stuffing | |
/api/v1/ (general) |
Implemented (apiLimiter) |
General API protection | ||
/api/v1/auth/refresh |
10 |
15 |
Planned (Phase 2) | Prevent refresh token flood |
/api/v1/ |
15 |
Planned (Phase 2) | Prevent TOTP brute force | |
/api/v1/ |
60 minutes | Planned (Phase 2) | Prevent email enumeration via flood | |
/api/v1/reports/* |
10 requests | 15 |
Planned (Phase 2) | Prevent expensive query abuse |
/api/v1/*/export |
5 requests | 60 minutes | Planned (Phase 2) | Prevent bulk data export |
Implementation
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
// Auth limiter — strict (applied to /auth/login and /auth/register)
const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5,
skipSuccessfulRequests: true, // Don't count successful logins
standardHeaders: true,
legacyHeaders: false,
message: {
error: 'Too many authentication attempts',
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: 60,
},
keyGenerator: (req) => req.ip || 'unknown',
})
// General API limiter (applied to all /api/v1/* routes)
const generalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
standardHeaders: true,
legacyHeaders: false,
handler: (_req, res) =>
res.status(429).json({
error: 'Too many requests',
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: 60,
}),
})
app.post('/api/v1/auth/login', authLimiter, loginHandler)
app.use('/api/v1/', generalLimiter)
IP Whitelisting for Webhooks
Webhooks from SEF (Serbian e-invoice portal) and FINA (Croatian HR-FISK) must bypass general rate limiting but are restricted to known IP ranges:
// Known webhook source IP ranges
const SEF_WEBHOOK_IPS = [
'185.54.144.0/24', // efaktura.mfin.gov.rs — verify with SEF portal docs
]
const FINA_WEBHOOK_IPS = [
'195.29.61.0/24', // FINA PKI infrastructure — verify with FINA
]
function isWebhookRequest(req: Request): boolean {
const clientIp = req.ip ?? req.socket.remoteAddress
return [...SEF_WEBHOOK_IPS, ...FINA_WEBHOOK_IPS].some((range) => ipRangeContains(range, clientIp))
}
// Webhook endpoint — IP-restricted, no general rate limit
app.post(
'/api/v1/webhooks/sef',
requireWebhookIp(SEF_WEBHOOK_IPS),
verifyWebhookSignature,
handleSefWebhook,
)
app.post(
'/api/v1/webhooks/fina',
requireWebhookIp(FINA_WEBHOOK_IPS),
verifyWebhookSignature,
handleFinaWebhook,
)
function requireWebhookIp(allowedRanges: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const clientIp = req.ip ?? req.socket.remoteAddress
const allowed = allowedRanges.some((range) => ipRangeContains(range, clientIp))
if (!allowed) {
return res.status(403).json({ error: 'Webhook source IP not allowed' })
}
next()
}
}
Note: Confirm exact SEF and FINA IP ranges from their integration documentation before deployment. Update SEF_WEBHOOK_IPS and FINA_WEBHOOK_IPS accordingly.
7. Input Validation
All inputs validated with Zod schemas:
Example: Invoice Validation
import { z } from 'zod'
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', 'HRK']),
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),
}),
),
});
// Middleware
function validate(schema: z.ZodSchema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body)
next()
} catch (error) {
res.status(400).json({ error: error.errors })
}
}
}
// Usage
app.post('/api/v1/invoices', validate(createInvoiceSchema), createInvoice)
8. File Upload Security
Allowed:
Allowed File Types
- Receipts: JPG, PNG,
PDF.PDF - Max size: 10MB per file
Validation
import multer from 'multer'
import path from 'path'
const upload = multer({
limits: { fileSize: 10 MB.* MIME1024 +* extension1024 validation.}, Stored// in10MB
CloudflarefileFilter: R2(req, EU.file, cb) => {
const allowedTypes = ['.jpg', '.jpeg', '.png', '.pdf']
const ext = path.extname(file.originalname).toLowerCase()
if (allowedTypes.includes(ext)) {
cb(null, true)
} else {
cb(new Error('Invalid file type'))
}
},
})
Virus Scanning (Planned)
Phase 2: Integrate ClamAV scanning.for virus scanning before upload to R2.
9.Audit Trail
LoggedAction Table (Immutable)
All mutations logged:
- Table name
- Action (INSERT, UPDATE, DELETE)
- User ID
- Timestamp
- Old values (UPDATE/DELETE)
- New values (INSERT/UPDATE)
- Client IP
- SQL query
Example Audit TrailLog Entry
{
"eventId": 12345,
"tableName": "invoices",
"action": "UPDATE",
"userId": "user-uuid",
"actionTimestamp": "2026-02-20T10:30:00Z",
"rowData": { "id": "invoice-uuid", "status": "draft" },
"changedFields": { "status": { "old": "draft", "new": "sent" } },
"clientIp": "192.168.1.10"
}
Audit Queries
// Get user activity
await prisma.loggedAction.findMany({
where: { userId: 'user-uuid' },
orderBy: { actionTimestamp: 'desc' },
take: 100,
})
// Get invoice history
await prisma.loggedAction.findMany({
where: {
tableName: 'invoices',
rowData: { path: ['id'], equals: 'invoice-uuid' },
},
})
Data Retention & Deletion
User Data Deletion (GDPR Right to Erasure)
Process:
- User requests deletion → POST /api/v1/account/delete
- Soft delete user record (mark
deletedAt) - Anonymize LoggedAction entries (replace user ID with "deleted-user")
- Delete PII (email, name)
- Keep financial records (required by law, minimum 5 years)
Soft Delete Implementation:
await prisma.user.update({
where: { id: userId },
data: {
email: `deleted-${userId}@example.com`,
fullName: 'Deleted User',
passwordHash: '',
deletedAt: new Date(),
},
})
Security Testing
Static Analysis
- ESLint: Security rules enabled (no-eval, no-unsafe-regex)
- TypeScript: Strict mode (catches type errors)
Dependency Scanning
- npm audit: Weekly checks
- Dependabot: Automatic PRs for vulnerabilities
Penetration Testing Plan
Frequency: Annual + after significant architecture changes
Provider: External certified firm (OSCP or CREST certified)
Environment: Staging only (staging.bilko.io) — LoggedActionnever (APPEND-ONLY)production without explicit CEO approval
Scope
| Test Approach | ||
|---|---|---|
| JWT tampering, refresh token theft, brute force, 2FA bypass | ||
| Cross-org data access, IDOR on UUIDs, query manipulation | ||
| Role |
||
| All endpoints for injection, auth bypass, mass assignment | ||
| Encrypted field bypass, IBAN/tax ID extraction | ||
| Malicious file upload, path traversal, SSRF | ||
|
SEF |
|
| Invoice amount manipulation, VAT calculation errors, status bypass |
On
Pre-Engagement GDPRChecklist
-
→Statement"deleted-user".ofFinancial entries retained 11 yearsWork (law).SoW)LoggedActionsignedneverwithdeleted.pentest firm - Staging environment set up with production-equivalent configuration
- Test data (fake organizations, fake invoices) loaded — no real customer data
- Bilko DBA grants read-only DB access to pentest firm for review (not write)
- Confirm staging SEF/FINA integrations are in test mode
- Legal: pentest authorization letter from CEO on file
Acceptance Criteria
- Zero Critical or High findings unmitigated before production launch
- Medium findings: each assessed, documented, and either fixed or accepted with justification
- Remediation SLAs:
- CRITICAL: 48 hours
- HIGH: 7 days
- MEDIUM: 30 days
- LOW: Next sprint boundary
Pentest Report Requirements
The pentest report must include:
- Executive summary (risk level, critical findings)
- Technical findings: CVSS score, proof of concept, affected endpoints
- Business impact statement for each finding
- Remediation recommendations
- Re-test results (confirm fixes for Critical and High)
10.Incident SecurityResponse HeadersPlan
Detection
- Monitor error rates (
Helmet.js)Sentry) - Monitor failed
login in 1 hour attemptsHeader (>10Value= spike, alert)Strict-Transport-Security (CPUmax-age=63072000;- Railway
includeSubDomains;metricspreloadmemory is leak)Content-Security-Policy Whatdefault-src'self';Response
script-src- Identify:
'unsafe-inline'
'self'the leak, breach?X-Content-Type-Options (datanosniffDDoS, unauthorizedX-Frame-Options access)DENY- Contain:
compromisedBlock revoke attackerX-Powered-By IP,Removed - Railway
Notification
- Internal: Slack alert to #security channel
- External: Email users if PII compromised (GDPR 72h requirement)
11. Pre-Launch Security Checklist (Pre-Launch)
-
JWT_SECRETJWT secrets generated(32+ chars, CSPRNG) — Railway env secret JWT_REFRESH_SECRET separate key(32+ chars)-
FIELD_ENCRYPTION_KEYHTTPSgeneratedenforced (32nobytesHTTPhex) — for PIB/JMBG/OIB/JIB + IBANallowed) -
HTTPSCORSenforcedwhitelist - configured
CORS:(nobilko.io only*) - Rate limiting
testedenabled (auth endpoints) - Helmet.js security headers
verifiedconfigured - bcrypt
roundspassword=hashing (12 rounds) -
AllPrisma queriesuseparameterizedorg-scoped(noWHEREraw SQL) -
ZodInput validationon(Zodall endpointsschemas) -
LoggedActionFiletriggeruploadactiverestrictionson(type,allsize) - Audit trail enabled (LoggedAction)
- Error
responsesmessages sanitized (no stack traces) - Dependabot alerts enabled
-
RailwayBackupregionstrategy= EU West confirmedtested -
DPAsIncidentsignedresponse(Railway,planVercel, Cloudflare, SendGrid)documented -
DataSecuritydeletionreviewworkflow testedcompleted
Related Documents
Compliance Framework:Compliance:compliance-framework.COMPLIANCE.mdData Encryption Policy:Deployment:data-encryption-policy.../infrastructure/DEPLOYMENT.mdKey Management Policy:key-management-policy.mdDPIA:data-protection-impact-assessment.mdBreach Response:data-breach-response-plan.mdSecurityTesting:security-testing-policy.mdBilko Security Docs:../../products/Bilko/docs/security/testing/TESTING-GUIDE.md
Approval
Last Updated:
Role
Name
Date
Signature
Final
Author
Compliance Architect
2026-02- 25
23Status: Compliance: Article OWASP CTOTop 10, GDPR 32 (Security DPOof Processing)
Engineering Lead