Skip to main content

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 — Bilko security architecture

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 via AZOP) | Zakon o računovodstvu RS/HR/BA | 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 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 — scales horizontally on Railway
  • Works with Next.js frontend and PWA
  • Industry standard for multi-tenant SaaS

2.2 Token Types

Token Lifetime Storage Contains
Access token 15 minutes Authorization: Bearer header (memory only) userId, organizationId, role
Refresh token 7 days httpOnly cookie (secure, sameSite=strict) userId

Refresh tokens stored as HMAC-SHA-256 hash in database. Raw token never stored. Rotation on every use.

2.3 JWT Auth Flow

sequenceDiagram
    actor User
    participant FE 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: 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)
        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
    FE->>API: POST /api/v1/auth/refresh (Cookie: refreshToken)
    API->>DB: SELECT refreshToken WHERE hash = hash(token) AND expiresAt > NOW()
    API->>API: Rotate: delete old, issue new
    API-->>FE: 200 { newAccessToken } + Set-Cookie: newRefreshToken

    Note over User,DB: 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 (RFC 6238) — Google Authenticator, Authy, 1Password, Microsoft Authenticator

  • Setup: POST /api/v1/auth/2fa/setup → QR code + base32 secret
  • Verify: POST /api/v1/auth/2fa/verify { code } → 2FA enabled
  • Login: POST /api/v1/auth/login{ requires2FA: true, tempToken }POST /api/v1/auth/2fa/login
  • Backup codes: 10 single-use codes, bcrypt-hashed, marked used after redemption

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 query scoped to authenticated user's organizationId via middleware:

// Injected by auth middleware on all /api/v1/* routes
app.use('/api/v1/*', (req, res, next) => {
  req.prismaWhere = { organizationId: req.user.organizationId };
  next();
});

// Applied to every Prisma query
await prisma.invoice.findMany({ where: { ...req.prismaWhere } });

UUID primary keys — no sequential IDs enabling 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

  • Frontend (Vercel): Automatic HTTPS, TLS 1.3
  • Backend (Railway): Automatic HTTPS, TLS 1.3
  • Cloudflare: TLS 1.3 termination at edge, re-encrypted to origin
  • HSTS: max-age=63072000; includeSubDomains; preload

4.2 At Rest: AES-256

Store Method Location
PostgreSQL AES-256 disk encryption (Railway TDE) Railway EU West (Frankfurt/Paris)
PostgreSQL backups AES-256 (Railway automatic) Railway EU West — 30-day retention
Tax IDs, IBAN AES-256-GCM field encryption (application layer) Railway EU West
Cloudflare R2 (files) AES-256 server-side Cloudflare EU region

4.3 Password Security

  • bcrypt, 12 salt rounds. Min 8 chars. Block top 10K common passwords. Retain last 5 hashes.

4.4 Financial Data Precision

All monetary amounts: NUMERIC(19,4) — never float. Exchange rates locked at transaction date.


5. OWASP Top 10 Mitigations

OWASP Risk Mitigation Status
A01: Broken Access Control RBAC + org-scoped WHERE + UUID PKs Designed
A02: Cryptographic Failures TLS 1.3 + AES-256 + bcrypt(12) + no PII in JWT Designed
A03: Injection Prisma ORM parameterized queries exclusively Designed
A04: Insecure Design Multi-tenant org isolation at DB layer, immutable audit Designed
A05: Security Misconfiguration Helmet.js, CORS whitelist (no *), sanitized errors Designed
A06: Vulnerable Components Dependabot + weekly npm audit + lock file committed Planned
A07: Auth Failures Rate limiting + JWT rotation + 2FA + bcrypt(12) Designed
A08: Software Integrity Signed commits + CI/CD + Dependabot Planned
A09: Logging Failures Immutable LoggedAction table + Railway logs + Sentry Designed
A10: SSRF Zod validation + allowlist for SEF/HR-FISK/FINA API calls Designed

6. Rate Limiting

Endpoint Limit Window
POST /api/v1/auth/login 5 req 15 min
POST /api/v1/auth/register 3 req 60 min
POST /api/v1/auth/refresh 10 req 15 min
GET /api/v1/reports/* 10 req 15 min
All other /api/v1/* 100 req 15 min

Implementation: express-rate-limit, per-IP, 429 Too Many Requests.


7. Input Validation

All inputs validated with Zod 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: JPG, PNG, PDF (receipts, invoice attachments). Max 10 MB.
  • MIME type + extension validation. Stored in Cloudflare R2 (EU region).
  • Planned Phase 2: ClamAV virus scanning before R2 upload.

9. Audit Trail

LoggedAction Table (APPEND-ONLY)

Field Type Description
eventId BIGSERIAL Auto-incrementing
tableName TEXT Which table was mutated
action TEXT INSERT / UPDATE / DELETE
userId UUID Who performed the action
actionTimestamp TIMESTAMPTZ UTC timestamp
rowData JSONB Full row snapshot before mutation
changedFields JSONB { field: { old: X, new: Y } }
clientIp INET Requester IP

Never delete from LoggedAction. On GDPR erasure: userId anonymized to "deleted-user". Financial log entries retained for legal 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. Pre-Launch Security Checklist

  • JWT_SECRET generated (32+ chars, CSPRNG) — Railway env secret
  • JWT_REFRESH_SECRET generated (separate key, 32+ chars)
  • FIELD_ENCRYPTION_KEY generated (32 bytes hex) — for tax IDs and IBAN
  • HTTPS enforced — no HTTP
  • CORS whitelist: bilko.io only
  • Rate limiting tested
  • Helmet.js headers verified
  • bcrypt rounds = 12
  • All Prisma queries use org-scoped WHERE
  • Zod validation on all endpoints
  • File upload restrictions active
  • LoggedAction trigger active on all tables
  • Error responses sanitized — no stack traces
  • Dependabot alerts enabled
  • Railway region = EU West confirmed
  • DPAs signed (Railway, Vercel, Cloudflare, SendGrid)
  • Data deletion workflow tested end-to-end


Approval

Role Name Date Signature
Author Compliance Architect 2026-02-23
CTO
DPO
Engineering Lead