Skip to main content

Security Architecture

Bilko — Security Architecture Document

Project: Bilko — Balkan Accounting SaaS Version: 1.0 Date: 2026-02-2523 Author: ALAICompliance Documentation TeamArchitect Status: FinalDraft Note:Reviewers: ArchitectureCTO, isDPO, documentedEngineering forLead implementation.Classification: Backend implementation is in progress (Phase 2).Confidential

This

Document document defines the security architecture for Bilko, a financial SaaS handling sensitive accounting data.


Security PrinciplesHistory

  1. Defense in Depth — Multiple layers of security (network, application, database)
  2. Least Privilege — Users and services get minimum necessary permissions
  3. Zero Trust — Verify every request, never assume trust
  4. Encryption Everywhere — Data encrypted in transit and at rest
  5. 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

ThreatVersion Attack VectorDate Bilko RiskAuthor MitigationChanges
JWT token theft0.1 XSS attack extracts access token from memory2026-02-23 HIGHCompliance ArchitectInitial draftattacker gains full user sessionCSP headers block inline scripts; access token not stored in localStorage
Session hijackingRefresh token cookie stolen via network or MITMHIGH — 7-day session takeoverhttpOnly + Secure + SameSite=Strict cookie; TLS 1.3 only
Credential stuffingAutomated login with leaked credentialsHIGH — financial platform targetedRate limiting (5 req/min); bcrypt 12 rounds; HIBP breach check
JWT algorithm confusionAttacker sends alg: none or switches HS256/RS256MEDIUMjsonwebtoken always specifies algorithm explicitly; RS256 enforced
Account enumerationTiming attack on login endpoint reveals valid emailsMEDIUMConstant-time response regardless of email existence

Tampering — Data Integrity Threats

ThreatAttack VectorBilko RiskMitigation
Invoice amount modificationMITM attack modifies invoice amounts in transitHIGH — financial fraudTLS 1.3 for all connections; HSTS with preload
Transaction record alterationUnauthorized user modifies financial recordsCRITICAL — accounting integrityLoggedAction audit trail (append-only); Prisma soft delete only
JWT payload manipulationAttacker decodes JWT, changes role: viewer to role: ownerHIGH — privilege escalationRS256 signature verification; any modification invalidates signature
Database record tamperingDirect DB access bypasses applicationHIGH — data integrity lossRailway access restricted to CTO only; no public DB port
File upload replacementUpload modified invoice PDF with different amountsMEDIUMFile stored by hash; original uploaded by authorized user; audit trail

Repudiation — Non-Traceability Threats

ThreatAttack VectorBilko RiskMitigation
Audit log bypassAttacker finds code path that skips LoggedActionHIGH — undetected fraudPrisma middleware applies audit to ALL model mutations; test coverage
LoggedAction deletionAdmin or attacker deletes audit recordsCRITICAL — compliance violationLoggedAction has no DELETE permission in RBAC; DB-level row security planned
Timestamp manipulationSystem clock skewed to invalidate audit timestampsLOWRailway NTP; JWT iat verified server-side
User denies action"I never deleted that invoice"MEDIUMAudit log captures: userId, IP, exact timestamp, old values, new values

Information Disclosure — Data Leakage Threats

ThreatAttack VectorBilko RiskMitigation
Cross-tenant data leakMissing organizationId WHERE clause on Prisma queryCRITICAL — GDPR breachOrg-scoping middleware on all routes; lint rule + automated isolation tests
Financial data in API errorsStack trace contains query with financial amountsHIGHProduction error handler returns only generic message + error ID
Tax ID (JMBG/OIB) exposureDB breach exposes plaintext personal citizen IDsCRITICAL — identity theft, irrevocableAES-256-GCM field-level encryption (Tier 1) via prisma-field-encryption (See ADR-014)
Tax ID (PIB/JIB) exposureDB breach exposes business tax IDsLOW — publicly available on APR/UIO portalsDisk-level encryption (Railway AES-256) + org-scoping + RBAC (Tier 2, See ADR-014)
IBAN exposureDB breach or API response over-returningMEDIUM — routinely shared for paymentDisk-level encryption + IBAN masked in list responses (last 4 digits only) (See ADR-014)
JWT contains PIIAccess token readable by any partyMEDIUMJWT contains only user ID, org ID, role — no email, name, or financial data
Log file leakageApplication logs contain email addresses or amountsMEDIUMLogging policy: never log request body for financial endpoints

Denial of Service — Availability Threats

ThreatAttack VectorBilko RiskMitigation
Authentication floodingBrute force login with millions of requestsHIGHRate limiting: 5 requests/15min on auth endpoints; Cloudflare DDoS protection
Report generation abuseRepeated complex report requests exhaust DBMEDIUMRate limiting: 10 requests/15min on /api/v1/reports/*; caching layer planned
File upload floodingUpload large files repeatedlyMEDIUM10MB limit; multer request counting; Cloudflare rate limiting at edge
Database connection exhaustionMany concurrent requests exceed pool sizeMEDIUMPrisma connection pool limits; Railway auto-scaling
Webhook replay floodingRepeat webhook calls to SEF/FINA integrationLOWIdempotency keys on e-invoice submissions; webhook signature verification

Elevation of Privilege — Access Control Threats

ThreatAttack VectorBilko RiskMitigation
RBAC bypass via role tamperingModify JWT role claim to gain admin accessCRITICALRS256 signature; role read from verified JWT payload only
Cross-tenant elevationOrg-1 user accesses Org-2 resources by guessing UUIDHIGH — multi-tenant SaaSUUID v4 unpredictable; org-scoped WHERE mandatory; 404 (not 403) on cross-org requests
Horizontal privilege escalationAccountant accesses another user's profile in same orgMEDIUMPer-user data scoped by userId; endpoints check req.user.id === resource.userId
API endpoint enumerationAttacker discovers undocumented admin endpointsLOWNo hidden admin endpoints; all endpoints in API spec; Cloudflare WAF
Dependency hijackingMalicious package injected via supply chainMEDIUMpackage-lock.json committed; Dependabot; npm audit in CIarchitecture

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, scales horizontally)
  • horizontally
  • Workson withRailway. mobile PWA
  • Industry standard

Token Types

Access Token

tokens
  • Lifetime: (15 minutes
  • min,
  • Storage:memory-only) Authorization:+ Bearerrefresh <token>tokens header
  • (7
  • Contains:days, UserhttpOnly ID,cookie). organizationRotation ID,on role
  • every
  • Refresh:refresh. AutomaticRevocation via refresh token

Refresh Token

  • Lifetime: 7 days
  • Storage: httpOnly cookie (not accessible to JavaScript)
  • Purpose: Obtain new access token
  • Rotation: New refreshhashed token issued on each refresh
  • Revocation: Storedstorage in database, can be invalidated
DB.

2.2 JWT Payload Example

{
  "sub": "user-uuid",
  "org": "org-uuid",
  "role": "admin",
  "iat": 1640000000,
  "exp": 1640000900,
  "jti": "unique-token-id"
}

jti (JWT ID) — unique token identifier used to prevent replay attacks and enable server-side token invalidation.

TokenAuth Flow

1.sequenceDiagram
    actor User
    logsparticipant inFE 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: AccessSELECT tokenuser WHERE email = ? (header)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->>DB: INSERT refreshToken (hashed, expiresAt)
        API-->>FE: 200 { accessToken } + RefreshSet-Cookie: tokenrefreshToken (httpOnlyhttpOnly, cookie)secure, 2.sameSite=strict)
        UserFE->>FE: makesStore requestaccessToken in GETmemory /api/v1/invoicesonly
    else Password invalid
        API-->>FE: 401 Unauthorized (Authorization:generic Bearer <no user enumeration)
    end

    Note over FE,API: 15 minutes later — access>)
   ← Protected resource

3. Access token expires
    (15 min) →FE->>API: POST /api/v1/auth/refresh (httpOnlyCookie: cookie)refreshToken)
    API->>API: NewRotate: accessdelete tokenold, issue new
    API-->>FE: 200 { newAccessToken } + NewSet-Cookie: refreshnewRefreshToken

    tokenNote 4.over UserUser,DB: logsLogout
    out →FE->>API: POST /api/v1/auth/logout
    API->>DB: DeleteDELETE refreshrefreshToken tokenWHERE fromuserId DB= ?
    API-->>FE: 204 No Content

Implementation2.3 (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:

Method: TOTP (Time-basedRFC One-Time6238) Password)

CompatibleGoogle with:Authenticator, Authy, 1Password

  • GoogleSetup: Authenticator
  • Authy
  • 1Password
  • Microsoft Authenticator

Setup Flow

1. User enables 2FA → POST /api/v1/auth/2fa/setup  QR code + base32 secret
(base32)
  • Verify: 2. User scans QR code in authenticator app → Generates 6-digit code 3. User verifies code → POST /api/v1/auth/2fa/verify { code }
  • Login: 200returns OK (2FA enabled)
  • Login Flow with 2FA

    1. User logs in → POST /api/v1/auth/login { email, password }
       ← 200 OK + { requires2FA: true, tempToken }
    
    2. User enters codePOST /api/v1/auth/2fa/login
    { tempToken, code }
       ← Access token + Refresh token
    

    Backup Codes

    Generate

  • Backup: 10 single-use backupcodes, codes during 2FA setup:

    • Stored bcrypt-hashed (bcrypt)
    • Used when authenticator unavailable
    • Marked as used after redemption

    3. Authorization (RBAC)

    Roles

    3.1 Role
    RolePermissions
    ownerFull access (edit org settings, invite users, delete data)
    adminManage invoices, expenses, contacts, reports (no org settings)
    accountantRead invoices/expenses, create reports (no edit)
    viewerRead-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

    Implementation3.2 Organization Scoping (Middleware)IDOR Prevention)

    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

    LevelLabelExamplesControls
    L4-ARestricted (Personal)JMBG, OIBAES-256-GCM field-level encryption (prisma-field-encryption) + HMAC-SHA256 hash columns + access log (See ADR-014)
    L4-BRestricted (Business/Financial)PIB, JIB, IBANDisk-level encryption (Railway AES-256) + TLS 1.3 + org-scoping + RBAC + API masking for IBAN (last 4 digits) (See ADR-014)
    L3ConfidentialFinancial amounts, bank statements, invoicesOrg-scoped access, TLS, PostgreSQL AES-256 at rest
    L2InternalEmail, name, address, phoneTLS, authenticated access only
    L1PublicOrganization name, public invoice referenceNo special controls

    L4 Restricted fields use a hybrid encryption approach per ADR-014: personal identifiers (JMBG, 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:

    • .env files 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

    // SAFEInjected by Prismaauth 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 limitingmiddleware on authall endpoints/api/v1/* (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 every endpoint
    • Organization-scoped queries (middleware)
    • No direct object reference (use UUIDs, not auto-increment IDs)
    // Organization scoping middlewareroutes
    app.use('/api/v1/*', (req, res, next) => {
      req.prismaWhere = { organizationId: req.user.organizationId };
      next();
    });
    
    // ApplyApplied to queriesevery Prisma query
    await prisma.invoice.findMany({ where: { ...req.prismaWhere } });
    

    UUID primary keys throughout — no sequential ID enumeration possible.


    4. Encryption

    6.4.1 SecurityIn MisconfigurationTransit: TLS 1.3

    Mitigations:

    • Helmet.js security headers
    • CORS whitelist (no * in production)
    • Error messages sanitized (no stack traces in production)
    • Disable X-Powered-By header

    Full Security Headers Configuration

    All securitytraffic headersHTTPS. appliedCloudflare viaTLS Helmet.js1.3 onat theedge, Expressre-encrypted API.to TheRailway. Next.js frontend applies equivalent headers viaHSTS: next.config.jsmax-age=63072000; includeSubDomains; preload.

    import

    4.2 helmetAt fromRest: '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'], }), )

    Next.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+.

    AES-256
    HeaderStore Expected ValueMethod PurposeLocation
    Content-Security-PolicyPostgreSQL RestrictiveAES-256 directivesTDE (Railway) PreventRailway XSSEU West (Frankfurt/Paris)
    Strict-Transport-SecurityPostgreSQL backups max-age=31536000;AES-256 includeSubDomains; preloadauto-backup ForceRailway HTTPSEU West — 30 days
    X-Frame-OptionsTax IDs (PIB/JMBG/OIB/JIB), IBAN DENYAES-256-GCM field encryption PreventApplication clickjackinglayer — Railway env secret
    X-Content-Type-OptionsCloudflare R2 (receipts, PDFs) nosniffAES-256 server-side PreventCloudflare MIMEEU sniffingregion

    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

    attacksurface
    OWASP RiskMitigationStatus
    A01: Broken Access ControlRBAC + org-scoped WHERE + UUID PKsDesigned
    Referrer-PolicyA02: Cryptographic Failures strict-origin-when-cross-originTLS 1.3 + AES-256 + bcrypt(12) + no PII in JWT Limit referrer leakageDesigned
    Permissions-PolicyA03: Injection DisablePrisma camera/mic/geo/paymentORM parameterized queries exclusively MinimizeDesigned
    A04: Insecure DesignMulti-tenant org isolation at DB layer, immutable auditDesigned
    A05: Security MisconfigurationHelmet.js, CORS whitelist (no *), sanitized errorsDesigned
    A06: Vulnerable ComponentsDependabot + weekly npm audit + lock filePlanned
    A07: Auth FailuresRate limiting + JWT rotation + 2FA + bcrypt(12)Designed
    A08: Software IntegritySigned commits + CI/CD + DependabotPlanned
    A09: Logging FailuresImmutable LoggedAction table + Railway logs + SentryDesigned
    A10: SSRFZod validation + allowlist for SEF/HR-FISK/FINA APIDesigned

    7.

    6. Cross-Site Scripting (XSS)

    Mitigations:

    • React auto-escapes output (default safe)
    • CSP headers (Content-Security-Policy)
    • Sanitize user input (Zod validation)
    • No dangerouslySetInnerHTML without 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 audit checks
    • 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)

    Rate Limiting

    Prevent brute force and abuse:

    Endpoint Limit Window StatusRationale
    POST /api/v1/auth/login, /register 5 requestsreq 115 minuteImplemented (authLimiter)Prevent credential stuffingmin
    POST /api/v1/* (general)auth/register 1003 requestsreq 160 minuteImplemented (apiLimiter)General API protectionmin
    POST /api/v1/auth/refresh 10 requestsreq 15 minutesPlanned (Phase 2)Prevent refresh token floodmin
    GET /api/v1/auth/2fa/loginreports/* 510 requestsreq 15 minutesPlanned (Phase 2)Prevent TOTP brute forcemin
    All other /api/v1/auth/forgot-password* 3100 requests60 minutesPlanned (Phase 2)Prevent email enumeration via flood
    /api/v1/reports/*10 requestsreq 15 minutesPlanned (Phase 2)Prevent expensive query abuse
    /api/v1/*/export5 requests60 minutesPlanned (Phase 2)Prevent bulk data exportmin

    Implementation


    7. Input Validation (Zod)

    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.


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

    • Receipts:

      Allowed: 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. 1024MIME *+ 1024extension },validation. //Stored 10MBin fileFilter:Cloudflare (req,R2 file,EU. 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 for virus scanning before upload to R2.scanning.


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

    1. User requests deletion → POST /api/v1/account/delete
    2. Soft delete user record (mark deletedAt)
    3. Anonymize LoggedAction entries (replace user ID with "deleted-user")
    4. Delete PII (email, name)
    5. 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 TestingAPPEND-ONLY)

    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) — never production without explicit CEO approval

    Scope

    UPDATE before{
    AreaField PriorityTest ApproachDescription
    Authentication & session managementeventId P0 — CriticalJWT tampering, refresh token theft, brute force, 2FA bypassAuto-incrementing
    Multi-tenant data isolationtableName P0Mutated — CriticalCross-org data access, IDOR on UUIDs, query manipulationtable
    RBAC & privilege escalationaction P1INSERT / High Role/ tampering, horizontal escalation, missing authorizationDELETE
    API securityuserId P1 — HighAll endpoints for injection, auth bypass, mass assignmentActor
    Financial data protectionactionTimestamp P1 — HighEncrypted field bypass, IBAN/tax ID extractionUTC
    File upload securityrowData P2Full row Medium Malicious file upload, path traversal, SSRFmutation
    Third-party integrationschangedFields P2{ field: Medium SEFold: /X, FINAnew: webhookY manipulation,} replay attacks}
    Business logicclientIp P2Requester — MediumInvoice amount manipulation, VAT calculation errors, status bypassIP

    Pre-Engagement

    On Checklist

    GDPR
      erasure:
    • userId Statement of"deleted-user". WorkFinancial entries retained 11 years (SoW)law). signedLoggedAction withnever 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:deleted.

    1. Executive summary (risk level, critical findings)
    2. Technical findings: CVSS score, proof of concept, affected endpoints
    3. Business impact statement for each finding
    4. Remediation recommendations
    5. Re-test results (confirm fixes for Critical and High)

    Incident10. ResponseSecurity PlanHeaders (Helmet.js)

    Detection

    • Monitor
    • errorrates(Sentry)
    • Monitor
    • failedloginattempts(>10hour=alert)
    • Railway
    • leak)

      Response

      1. Identify:
      2. Whatisthebreach?(dataleak,DDoS,unauthorizedaccess)
      3. Contain: Block attacker IP, revoke compromised tokens
      4. Eradicate: Fix vulnerability, patch code
      5. Recover: Restore from backup if needed
      6. Document: Write post-mortem, update security docs
      7. Notification

        • Internal: Slack alert to #security channel
        • External: Email users if PII compromised (GDPR 72h requirement)
        Header Value
        Strict-Transport-Security max-age=63072000; inincludeSubDomains; 1preload
        Content-Security-Policy default-src metrics'self'; (CPUscript-src spike,'self' memory'unsafe-inline'
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        X-Powered-By Removed

        11. Pre-Launch Security Checklist (Pre-Launch)

        • JWT secretsJWT_SECRET generated (32+ chars, CSPRNG) — Railway env secret
        •  JWT_REFRESH_SECRET separate key (32+ chars)
        • HTTPSFIELD_ENCRYPTION_KEY enforcedgenerated (no32 HTTPbytes allowed)hex) — for PIB/JMBG/OIB/JIB + IBAN
        • CORSHTTPS whitelistenforced
        • configured
        • (noCORS: *)bilko.io only
        • Rate limiting enabled (auth endpoints)tested
        • Helmet.js security headers configuredverified
        • bcrypt passwordrounds hashing= (12 rounds)
        • All Prisma queries parameterizeduse (noorg-scoped raw SQL)WHERE
        • InputZod validation (Zodon schemas)all endpoints
        • FileLoggedAction uploadtrigger restrictionsactive (type,on size)
        • all
        •  Audit trail enabled (LoggedAction)tables
        • Error messagesresponses sanitized (no stack traces)
        • Dependabot alerts enabled
        • BackupRailway strategyregion tested= EU West confirmed
        • IncidentDPAs responsesigned plan(Railway, documentedVercel, Cloudflare, SendGrid)
        • SecurityData reviewdeletion completedworkflow tested


        Last

        Approval

        Updated: Status:FinalCompliance:OWASPTop10,GDPRArticle32(SecurityofProcessing)

        RoleNameDateSignature
        AuthorCompliance Architect2026-02-2523
        CTO
        DPO
        Engineering Lead