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)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

  • Lifetime: 
  • Storage: 
  • Refresh:
  • Automaticvia refreshrotation

    Refresh Token

    • Lifetime: 
  • Storage: 
  • toJavaScript)
  • Rotation:
  • New
    TokenLifetimeStorageContains
    Access Tokentoken 15 minutes Authorization: Bearer <token> header (memory onlyonly) userId, not localStorage)
  • Contains: user ID (sub), organization ID (org),organizationId, role
  • Refresh token 7 days httpOnly cookie (notsecure, accessiblesameSite=strict) userId
    refresh

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

  • Revocation: Stored hashed in database, invalidated on logout
  • 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 app
      • POST /api/v1/auth/2fa/verify { code } → 2FA enabled
  • Login:

    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, storedcodes, bcrypt-hashed.

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

    backupretention
    Store Method NotesLocation
    PostgreSQL (Railway EU West) AES-256 disk encryption (Railway default)TDE) Frankfurt or Paris region —Railway EU dataWest residency(Frankfurt/Paris)
    PostgreSQL backups AES-256 (Railway automatic) Railway EU West — 30-day rollingretention
    Tax IDs, IBANAES-256-GCM field encryption (application layer)Railway EU West
    Cloudflare R2 (file storage)files) AES-256 server-side Receipts,Cloudflare invoiceEU PDFsregion

    4.3 Password Security

    • Algorithm: bcrypt, 12 salt rounds
    • Requirements:rounds. Min 8 chars, uppercase + lowercase + digit
    • Common password list:chars. Block top 10K knowncommon weakpasswords. passwords
    • Retain
    • History: Previouslast 5 password 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 on every query + UUID PKs Designed
    A02: Cryptographic Failures TLS 1.3 + AES-256 at rest + bcrypt(12) + no PII in JWTsJWT Designed
    A03: Injection Prisma ORM parameterized queries exclusively — zero raw SQL for user input Designed
    A04: Insecure Design Multi-tenant scopingorg isolation at DB layer, immutable audit trail Designed
    A05: Security Misconfiguration Helmet.js headers,js, CORS whitelist (no *), sanitized error messageserrors Designed
    A06: Vulnerable Components Dependabot alerts + weekly npm audit + lock file committed Planned
    A07: Auth Failures Rate limiting (5/15min auth) + JWT rotation + 2FA + bcryptbcrypt(12) Designed
    A08: Software Integrity Signed commits + CI/CD pipeline + Dependabot Planned
    A09: Logging Failures Immutable LoggedAction audit trailtable + Railway logs + Sentry Designed
    A10: SSRF InputZod validation (Zod schemas),+ allowlist for externalSEF/HR-FISK/FINA API calls (SEF, eRačun) Designed

    6. Rate Limiting

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

    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 file
    • Validation: MIME type + extension check
    • validation.
    • Storage:Stored in Cloudflare R2 (EU region), not served from app server.
    • Planned (Phase 2):2: ClamAV virus scanning before R2 uploadupload.

    9. Audit Trail

    LoggedAction Table (Immutable — APPEND-ONLY)

    Every

    mutationlogged:

    • which
    • who
    • full
    • requesteraddress
      Field TypeDescription
      eventIdBIGSERIALAuto-incrementing
      tableName TEXT Which table was affectedmutated
      action TEXT INSERT / UPDATE / DELETE
      userId UUID Who performed the action
      actionTimestamp TIMESTAMPTZ UTC timestamp
      rowData JSONB Full row snapshot (before state)mutation
      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)
      • separate
      • fromFIELD_ENCRYPTION_KEY JWT_SECRET)generated (32 bytes hex) — for tax IDs and IBAN
      • HTTPS enforced (no HTTP)HTTP
      • CORS whitelist configured (onlywhitelist: bilko.io)io only
      • Rate limiting enabled and tested
      • 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 stack traces)traces
      • Dependabot alerts enabled
      • PostgreSQLRailway onregion Railway= EU West regionconfirmed
      • 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