Security — technical detail
Updated: May 7, 2026
Looking for the plain-language version? Read How we protect your data instead. This page is the engineering-faithful companion — written for developers, security researchers, and partners running due diligence. It's a factual walkthrough of the primitives we have in production.
I. Identity data — Bank iD and Stripe Identity
To verify that our professionals can legally work, we use two external KYC providers. Neither of them stores sensitive identity data permanently on our servers.
Bank iD (OIDC). When a worker chooses Bank iD verification, their bank signs a JWT containing only verified fields (name, date of birth, address, ID document number) — never bank login credentials. We verify the token against Bank iD's public JWKS (env BANKID_JWKS_URL) and persist the structured result into worker_identity_verifications. The raw token is discarded after verification.
Stripe Identity. When a worker chooses to scan an ID document, the device uploads images directly to Stripe — physical images of passports / ID cards never pass through our backend. Stripe sends a webhook with verified | requires_input | canceled plus structured fields (name, date of birth, document number) through our single KYC endpoint in worker-identity.service.ts. The images stay with Stripe under their retention policy.
Audit and retention. Every state change is recorded in worker_identity_audit_events (the worker-identity-audit module). Structured verification data is retained for the legally-required duration of the employment relationship; raw webhook payloads and pending-verification sessions are purged after 60 days.
II. Tokens and sessions
On sign-in, the client receives two tokens: a short-lived JWT access token (15 minutes) and a re-usable refresh token (14 days sliding, 365 days absolute).
The refresh token is opaque. A 64-character hex random string — not a JWT. It can't be "decoded" outside our backend. It rotates on every use: the old token is immediately marked consumed, and a new one is issued.
Reuse detection via family_id. Every refresh token carries a family_id identifying the parent authorization (e.g. the original sign-in from a specific device). If a consumed token from the same family is presented again — anywhere in the world — the entire family is revoked immediately and every active session descended from that authorization is signed out. This catches cookie theft / replay attacks within seconds. Implementation: apps/api/src/modules/auth/, table refresh_tokens.
Active devices. At /account/security/sessions (web) and /sessions (mobile), users see all active sessions, last activity, and the device / location (derived from User-Agent + current IP — IPs are not persisted historically). Sessions can be revoked individually or in bulk; revocation invalidates the family, not just a single token.
III. Files and documents
Contracts, profile photos, job-related documents — everything uploaded to FixIt is stored on our own MinIO cluster (S3-compatible), never on a publicly-accessible CDN.
The database never stores URLs. Only object keys are persisted (e.g. users/abc123/avatar.jpg). URLs are composed at response time via AssetUrlService (apps/api/src/modules/storage/asset-url.service.ts) — every URL has a short expiry, and storage providers can be swapped without rewriting the database.
Three-tier access control (apps/api/src/modules/storage/storage.controller.ts):
- Public assets (company logos, public professional avatars) — no authorization required, but issued as signed URLs with a short expiry.
- Authenticated assets (contracts, job documents) — JWT validation + ownership check before issuing a signed URL.
- Tenant-scoped assets (internal company documents) — additionally checks the
X-Active-Company-Idheader, so an admin of company A cannot access company B's files.
IV. Encryption in transit and at rest
In transit. TLS 1.3 (mandatory), HSTS with subdomain preload, CSP with nonce. Configured at the nginx reverse proxy with Cloudflare in front as WAF. All traffic between mobile/web and the API is encrypted between the client and our edge.
At rest. PostgreSQL 16 with disk-level encryption (LUKS on the host). Database backups are encrypted with a separate key and stored outside the database container — see bun run db:backup:dev / db:backup:staging / db:backup:prod, which invoke the on-VPS backup script.
Cross-tenant scope safety. Public reads on the companies table go through excludePlatformCompany() (apps/api/src/common/scopes/exclude-platform-company.scope.ts) so the internal FIXIT_PLATFORM shell employer cannot leak into customer-facing listings, search, or Elasticsearch indexing.
User input. Free-text fields go through stripHtmlTags() sanitisation (apps/api/src/common/utils/sanitize.utils.ts) before persistence — no regex, a vetted parser. Suspicious HTML and null bytes are caught by the global SanitizeNullBytesInterceptor. User-supplied URLs (e.g. portfolio links) pass through an SSRF guard (@fixit/url-security) before the server fetches them.
V. GDPR and contacts
FixIt App s.r.o. is the data controller under Article 4 GDPR. Our Privacy Policy covers the legal framework and your rights.
- Data protection contact: legal@fixit.app
- Supervisory authority: Czech Office for Personal Data Protection — Pplk. Sochora 27, 170 00 Prague 7
Reporting security issues. If you discover a vulnerability, please reach out to security@fixit.app. We acknowledge within 48 hours. Please don't disclose publicly before we ship a fix — we follow coordinated disclosure.
This page is a factual description. If you find a discrepancy between what's written here and what we actually do, that's a bug — email security@fixit.app and we'll fix it.