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 — 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:

  1. POST /api/v1/auth/2fa/setup → QR code + base32 secret
  2. User scans QR code in authenticator app
  3. POST /api/v1/auth/2fa/verify { code } → 2FA enabled

Login with 2FA:

  1. POST /api/v1/auth/login{ requires2FA: true, tempToken }
  2. 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 affected
  • action — INSERT / UPDATE / DELETE
  • userId — who performed the action
  • actionTimestamp — UTC timestamp
  • rowData — 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


Approval

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