back to work

Privacy-First Salary Transparency Platform

Period 2026Context Volunteer projectRole Full-Stack Developer

A salary and interview transparency platform that uses single-use contribution tokens and signed receipts to separate anonymous submission from the access credit granted to the contributor.

Problem

People are less likely to share exact salary and interview data when the platform can directly link each submission to their account.

Contribution

I designed the privacy model and built the Go backend, Next.js PWA, CI pipeline, and deployment on Google Cloud Run.

Result

Pre-launch product with the core privacy and contribution flows implemented.

System

A Go modular monolith with Ent and PostgreSQL, Redis-backed Asynq workers, Atlas migrations, and deployment on Google Cloud Run.

Main challenge

Keeping submissions anonymous while still enforcing access rules and preventing abuse.

Overview

The platform lets people share exact salary and interview data for the Turkish market in Turkish or English. Access expands from limited, stable previews to full reads after verification and one contribution.

Privacy boundary

The database does not store a direct key from a user account to an anonymous salary or interview entry.

The design prevents the application database from directly linking an account to an anonymous entry. It does not claim cryptographic anonymity against infrastructure logs, network metadata, timing correlation, or an operator with access to those systems.

Preview and timestamp handling

Salary previews use a coarse period selected by the contributor rather than an exact submission timestamp, reducing unnecessary correlation while keeping entries understandable.

Contribution-based access

The token is validated and consumed in the same transaction as entry creation, so malformed submissions do not spend it. The signed receipt can later grant contribution credit without storing a user-to-entry relationship.

Verification & badges

Work or student email verification uses a peppered six-digit OTP with a 10-minute TTL and five attempts. Only a unique peppered email HMAC is persisted. The current badge level is embedded in the submission token, and verification claims expire after twelve months.

Reads, tiers & anti-scraping

Anonymous visitors see three rows, registered users see ten, and full access requires verification plus one contribution. Values remain exact at every tier.

PostgreSQL selects a stable per-viewer preview using md5(seed||id), so refreshes do not rotate new rows into view and no preview table is required. Sensitive routes also use per-IP limits with explicit trusted-proxy handling; listings omit submission dates and use the coarse user-selected period for freshness.

Engineering & operations

The Go modular monolith separates bounded contexts through domain, application, infrastructure, and HTTP layers. Ent defines the schema; Atlas produces versioned migrations and CI rejects drift.

Asynq handles OTP mail, expiry sweeps, backfills, and password-reset work outside the request path. Static analysis, race tests, secret and vulnerability scans, plus privacy-specific tests gate three distroless Cloud Run images: API, worker, and migration job.

Technical deep dive
Contribution flow — anonymous entry and access creditAn authenticated user receives a single-use contribution token. The submission endpoint accepts that token without an authenticated account context, validates and consumes it, stores the anonymous entry without a user identifier, and returns a signed receipt used to unlock access.
01Tokenauthed · single-use · 24h
02Redeemunauthenticated POST
03Entrysalary period · no server time
04ReceiptHMAC-signed nonce
05Claimcredit, no link
06Unlockfull read access
System topology — Go modular monolithA Next.js PWA over a Go API split into bounded contexts with enforced layering; PostgreSQL and Redis carry state, and an Asynq worker owns everything that shouldn't run on the request path — OTP mail, expiry sweeps, and backfills.
System topology — Go modular monolithA Next.js PWA over a Go API split into bounded contexts with enforced layering; PostgreSQL and Redis carry state, and an Asynq worker owns everything that shouldn't run on the request path — OTP mail, expiry sweeps, and backfills.Bounded contexts — domain / app / infra / httpREST · /api/v1routesEnt · transactionstokens · limitsenqueue jobsNext.js PWAApp Router · TR / ENpresentation-onlyGo API — chi/api/v1 · modular monolithGCP Cloud RunAuth & sessionsIdentityVerificationCompaniesEntriesInterviewsRolesPrivacy & auditPostgreSQL 16Ent · Atlas migrationspg_trgm fuzzy searchRedis 7pending tokens · OTPrate limits · AsynqAsynq workerOTP mail → SMTPhourly sweeps · backfills
What the database can and cannot sayIdentity records and anonymous entries are stored on separate sides of the schema; anonymous rows contain no user identifier or server-generated submission timestamp.
What the database can and cannot sayIdentity records and anonymous entries are stored on separate sides of the schema; anonymous rows contain no user identifier or server-generated submission timestamp.Identity side — knows whoAnonymous side — knows whatno joinusersemail as peppered HMACcontribution_count onlyverification_claimsbadge · 12-month TTLno company · no entrysessionsrotating refresh tokensreuse detectionaudit_logs4 actions · no targetno IP · no metadataentriessalary_period · roleno user_id · no server timestampinterviewsexperience_period · outcomeno user_id · no server timestampspent_tokenstoken hash onlytimeless replay ledgerclaimed_receiptsnonce hash onlysingle-use · permanent
Tiered reads with exact valuesEvery tier sees exact values; access controls row count. A deterministic preview prevents refresh-based enumeration from revealing a rotating sample.
01Teaseranonymous · 3 rows
02Previewregistered · 10 · seeded
03Contributeone entry via token
04Full readverified · unbounded
Key decisions & why
01

No direct account-to-entry key

The application database does not store a direct relationship between an account and an anonymous entry. Infrastructure logs, network metadata, timing correlation, and operator access remain outside this database-level guarantee.

02

User-selected salary periods

A coarse period such as 2026 H1 keeps salary data useful over time without recording the exact moment an entry was created. It represents the validity of the salary, not the submission event.

03

Single-use tokens separate submission and credit

A single-use token lets an unauthenticated endpoint accept content; an HMAC-signed receipt returns contribution credit later. Validation and spending are transactional, so malformed requests do not consume tokens.

04

Stable previews

Ordering by md5(seed||id) gives each viewer a stable subset without stored preview state. Refreshing does not reveal a new sample, reducing a simple enumeration path while keeping exact values visible.

05

Modular Go monolith

One deployable keeps token spending and entry creation in a single transaction. Bounded contexts, Ent/Atlas drift checks, and privacy-specific tests protect those invariants as the code changes.

Stack
  • Go
  • TypeScript
  • chi
  • Next.js
  • PostgreSQL
  • Ent
  • Atlas
  • Cloud SQL for PostgreSQL
  • Redis
  • Asynq
  • Memorystore for Redis
  • Google Cloud Run