Skip to main content

Security Architecture

Security Architecture Document

Project: Bilko — BalkanSecurity AccountingArchitecture SaaS Version: 1.0 Date: 2026-02-23 Author: Compliance Architect

Status: DraftPLANNED (backend not built yet, security measures documented for implementation)

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


Security Principles

  1. Reviewers:Defense in Depth CTO, DPO,Multiple Engineeringlayers Leadof security (network, application, database)
  2. Classification:Least Privilege Confidential— 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

Document History

Identity Threats
VersionThreat DateAttack Vector AuthorBilko Risk ChangesMitigation
0.1JWT token theft 2026-02-23XSS attack extracts access token from memory ComplianceHIGH Architect— attacker gains full user session InitialCSP draftheaders block inline scripts; access token not stored in localStorage
Session hijackingRefresh token cookie stolen via network or MITMHIGH7-day session takeoverhttpOnly + Secure + SameSite=Strict cookie; TLS 1.3 only
Credential stuffingAutomated login with leaked credentialsHIGH — financial platform targetedRate limiting (5 req/15min); 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 architectureplanned
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 (PIB/JMBG/OIB/JIB) exposureDB breach exposes plaintext tax IDsCRITICAL — identity theftAES-256-GCM field-level encryption before storage
IBAN exposureDB breach or API response over-returningCRITICAL — financial fraudAES-256-GCM field-level encryption; IBAN masked in list responses
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 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, (scales horizontallyhorizontally)
  • 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.each Accessrefresh
  • tokens
  • 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 (15JWT min,ID) memory-only) + refresh tokens (7 days, httpOnly cookie). Rotation on every refresh. Revocation via hashedunique token storageidentifier inused DB.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 + base32secret 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 } ← 200 OK (2FA enabled)
  • Login:

    Login returnsFlow 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:

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

    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

    LevelLabelExamplesControls
    L4RestrictedPIB, JMBG, OIB, JIB (tax IDs), IBANAES-256-GCM field-level encryption + access log
    L3ConfidentialFinancial amounts, bank statements, invoicesOrg-scoped access, TLS, PostgreSQL AES-256 at rest
    L2InternalEmail, name, address, phoneTLS, authenticated access only
    L1PublicOrganization Scopingname, public invoice referenceNo special controls

    L4 Restricted fields must be encrypted at the column level using AES-256-GCM before persistence. Disk-level encryption alone is insufficient for these fields.


    Encryption

    In Transit: TLS 1.3

    All traffic encrypted via HTTPS:

    • Frontend (IDORVercel): Prevention)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: Not needed (disk encryption sufficient for accounting data)

    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

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

    primary
      keys
    • Helmet.js throughoutsecurity headers
    • CORS whitelist (no sequential* IDin enumerationproduction)
    • possible.

    • Error
      messages

      4.sanitized Encryption

      (no

      4.1stack Intraces Transit:in TLSproduction)

    • 1.3
    • Disable X-Powered-By header

    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.2
    import 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'], }));

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

    StoreHeader MethodExpected Value LocationPurpose
    PostgreSQLContent-Security-Policy AES-256Restrictive TDE (Railway)directives RailwayPrevent EU West (Frankfurt/Paris)XSS
    PostgreSQL backupsStrict-Transport-Security AES-256max-age=31536000; auto-backupincludeSubDomains; preload RailwayForce EU West — 30 daysHTTPS
    Tax IDs (PIB/JMBG/OIB/JIB), IBANX-Frame-Options AES-256-GCM field encryptionDENY ApplicationPrevent layer — Railway env secretclickjacking
    Cloudflare R2 (receipts, PDFs)X-Content-Type-Options AES-256 server-sidenosniff CloudflarePrevent EUMIME region

    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

    Minimizeattack
    OWASP RiskMitigationStatus
    A01: Broken Access ControlRBAC + org-scoped WHERE + UUID PKsDesignedsniffing
    A02: Cryptographic FailuresReferrer-Policy TLS 1.3 + AES-256 + bcrypt(12) + no PII in JWTstrict-origin-when-cross-origin DesignedLimit referrer leakage
    A03: InjectionPermissions-Policy PrismaDisable ORM parameterized queries exclusivelycamera/mic/geo/payment Designed
    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 APIDesignedsurface

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

    6. Rate Limiting

    Prevent brute force and abuse:

    other
    Endpoint Limit Window Rationale
    POST /api/v1/auth/login 5 reqrequests 15 minminutesPrevent credential stuffing
    POST /api/v1/auth/register 3 reqrequests 60 minminutesPrevent bulk account creation
    POST /api/v1/auth/refresh 10 reqrequests 15 minminutesPrevent refresh token flood
    GET /api/v1/reports/*auth/2fa/login 105 reqrequests 15 minminutesPrevent TOTP brute force
    All/api/v1/auth/forgot-password 3 requests60 minutesPrevent email enumeration via flood
    /api/v1/* (general) 100 reqrequests 15 minminutesGeneral API protection
    /api/v1/reports/*10 requests15 minutesPrevent expensive query abuse
    /api/v1/*/export5 requests60 minutesPrevent bulk data export

    Implementation

    import rateLimit from 'express-rate-limit';
    import RedisStore from 'rate-limit-redis';
    
    // Auth limiter — strict
    const authLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,            // 15 minutes
      max: 5,
      standardHeaders: true,
      legacyHeaders: false,
      message: { error: 'Too many attempts. Try again in 15 minutes.' },
      // Use IP + email combination as key to prevent distributed attacks
      keyGenerator: (req) => `${req.ip}:${req.body?.email ?? 'unknown'}`,
    });
    
    // General API limiter
    const generalLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 100,
      standardHeaders: true,
      legacyHeaders: false,
      skip: (req) => isWebhookRequest(req), // Webhooks bypass general limiter
    });
    
    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

    (Zod)

    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:

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

    / mutationold:
    FieldArea DescriptionPriorityTest Approach
    eventIdAuthentication & session management Auto-incrementingP0 — CriticalJWT tampering, refresh token theft, brute force, 2FA bypass
    tableNameMulti-tenant data isolation MutatedP0 table— CriticalCross-org data access, IDOR on UUIDs, query manipulation
    actionRBAC & privilege escalation INSERTP1 / UPDATEHigh Role DELETEtampering, horizontal escalation, missing authorization
    userIdAPI security ActorP1 — HighAll endpoints for injection, auth bypass, mass assignment
    actionTimestampFinancial data protection UTCP1 — HighEncrypted field bypass, IBAN/tax ID extraction
    rowDataFile upload security FullP2 row beforeMedium Malicious file upload, path traversal, SSRF
    changedFieldsThird-party integrations {P2 field: {Medium SEF X,/ new:FINA Ywebhook }manipulation, }replay attacks
    clientIpBusiness logic RequesterP2 IP— MediumInvoice amount manipulation, VAT calculation errors, status bypass

    On

    Pre-Engagement GDPRChecklist

    erasure:
      userId
    • Statement "deleted-user".of Financial entries retained 11 yearsWork (law).SoW) LoggedActionsigned neverwith deleted.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:

    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)

    10.Incident SecurityResponse HeadersPlan

    Detection

    • Monitor error rates (Helmet.js)Sentry)
    • Monitor
    • failedloginattempts(>10in1hour=alert)(CPUspike,memoryleak)Whatisthebreach?(dataleak,DDoS,unauthorizedaccess)
    • Contain:
    • BlockattackerIP,revokecompromised
      Header Value
      Strict-Transport-Security max-age=63072000;
    • Railway includeSubDomains;metrics preload
    • Content-Security-Policy default-src 'self';

      Response

      script-src
        'self'
      1. Identify: 'unsafe-inline'
      X-Content-Type-Options nosniff
      X-Frame-Options DENY
      X-Powered-By Removed
      tokens
    • Eradicate: Fix vulnerability, patch code
    • Recover: Restore from backup if needed
    • Document: Write post-mortem, update security docs
    • 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_KEYHTTPS generatedenforced (32no bytesHTTP hex) — for PIB/JMBG/OIB/JIB + IBANallowed)
      • HTTPSCORS enforced
      • whitelist
      • configured CORS:(no bilko.io only*)
      • Rate limiting testedenabled (auth endpoints)
      • Helmet.js security headers verifiedconfigured
      • bcrypt roundspassword =hashing (12 rounds)
      • All Prisma queries useparameterized org-scoped(no WHEREraw SQL)
      • ZodInput validation on(Zod all endpointsschemas)
      • LoggedActionFile triggerupload activerestrictions on(type, allsize)
      • tables
      •  Audit trail enabled (LoggedAction)
      • Error responsesmessages sanitized (no stack traces)
      • Dependabot alerts enabled
      • RailwayBackup regionstrategy = EU West confirmedtested
      • DPAsIncident signedresponse (Railway,plan Vercel, Cloudflare, SendGrid)documented
      • DataSecurity deletionreview workflow testedcompleted


      Approval

      Last

      Updated: 20Status:PLANNEDBackendnotbuiltyet,securitymeasurestobeimplementedCompliance:OWASPTopGDPRArticle32(SecurityofProcessing)

      RoleNameDateSignature
      AuthorCompliance Architect2026-02-23
      CTO
      DPO
      Engineering10, Lead