Designing a fraud-signal backend that is honest about its own scaling limits.
FastAPI service that fingerprints visitors, links probable identities across browsers and scores risk built to keep public voting fair, with tests and AWS deployment scaffolding.
- Lead backend engineer
- Jul 2026 — Aug 2026
- Small team; I wrote the initial implementation and most of the commits.
- Backend service · HTTP API
- Python
- FastAPI
- SQLAlchemy 2
- Alembic
- Pydantic v2
- APScheduler
- PostgreSQL
- SQLite
- Docker
- AWS ECS Fargate
- AWS RDS
- pytest
A SeamHealth Group service. Detection signals and scoring are described only at a high level, because publishing them would make them easier to evade.
Context
Public voting and feedback attract duplicate and automated submissions. Device Intelligence gives the frontend a way to recognise a returning device, link probable identities across browsers, and score how risky a session looks.
It is the backend half of the platform, deployed independently; the Clientshot Pulse voting app reports to it from the browser.
My responsibility
I owned
- The initial backend: API, data model, migrations and background jobs
- Production hardening: authentication, fail-closed secrets and deploy artifacts
- The AWS deployment path: ECS Fargate, RDS Postgres and Secrets Manager
- Location resolution (GPS first, IP fallback) and the frontend reporting hook in Clientshot Pulse
- Unit and integration tests 14 test files
I collaborated on
- Feature work and review with a second engineer
- The Clientshot Pulse voting frontend, built mainly by another engineer
The challenge
A risk service has to make useful judgements from noisy signals, run periodic recalculation in the background, and be safe to deploy wrong configuration in a security service is itself a vulnerability.
It also had to be simple enough to run locally with nothing but pip, and still have a credible path to production.
Constraints
- Zero-setup local runs
- Engineers should be able to start it with pip and SQLite, no database server, no queue.
- Spoofable inputs
- Client-reported data and forwarded headers can be faked, so the service must decide what to trust.
- Background work
- Similarity recalculation, duplicate-device merging, risk updates and session expiry all run on a schedule.
Architecture
- Browser fingerprint + location report (Clientshot Pulse)
- FastAPI routes
- Pydantic v2 validation
- /health
- Services
- Risk engine
- Device graph
- APScheduler: similarity, merging, risk updates, session expiry
- SQLAlchemy 2 + Alembic
- SQLite locally → Postgres (RDS) in production
- Layered: HTTP concerns in the API, business logic in services and the risk engine, persistence in infrastructure.
- One DATABASE_URL switches SQLite to Postgres; Alembic migrations run on container start.
Key decisions
01Run the scheduler in-process — and say so loudly
- Four recurring jobs need to run, but adding a queue or cron service makes local setup heavier.
- External cron calling job endpoints
- A task queue with separate workers
- APScheduler inside the API process
- In-process scheduling needs no extra infrastructure, but every replica runs every job — so the service can’t scale horizontally without duplicating work.
- Use APScheduler in-process, pin gunicorn to one worker, and document the single-process constraint in the README.
- It fits current traffic, and an explicit, written limit is safer than a hidden one someone discovers by scaling out.
- Simple to run and deploy, with the migration path written down: move jobs out of process before adding replicas.
02Fail closed on secrets
- The secret key signs visitor cookies and CSRF tokens, and a development default had to exist for local runs.
- Warn when the default key is used
- Refuse to start in production with the default key
- Refusing to boot can block a deploy; a warning can be missed and leave tokens forgeable.
- The app refuses to start when ENVIRONMENT=production and the committed default key is in use.
- For a security service, a failed deploy is better than a running service with forgeable tokens.
- Misconfiguration surfaces at deploy time, not in an incident.
03Trust forwarded IPs only from known proxies
- Behind a proxy, the real client IP arrives in X-Forwarded-For — a header any client can forge.
- Always trust X-Forwarded-For
- Never trust it
- Trust it only from configured proxy addresses
- Always trusting it lets attackers choose their IP; never trusting it loses the real IP behind a load balancer.
- Honour X-Forwarded-For only when the request comes from an address in TRUSTED_PROXY_IPS.
- IP-based signals are only worth having if they can’t be set by the client.
- Safe defaults locally, correct client IPs behind a configured load balancer.
Implementation
- Data model
- Visitors, devices, sessions and behaviour, with Alembic migrations.
- Location
- GPS-first location with IP-based fallback.
- Tests
- Unit and integration tests 14 files.
Production
- Container
- Non-root Docker image; migrations on start; gunicorn with a single Uvicorn worker; a /health endpoint for the load balancer.
- AWS path
- An ECS Fargate task definition with RDS Postgres in a private subnet and secrets from Secrets Manager.
- TLS
- Terminated by a reverse proxy in front of the service, as documented.
Outcome
The service runs locally with no setup, deploys as a hardened container, and has a documented path to AWS — with its scaling limit written down rather than discovered.
- Commits authored
- 10 of 16
- Including the initial implementation, from git history.
- Test files
- 14
- Unit and integration.
- Fraudulent votes prevented
- 16 of 16
- Impact metric unavailable.
Lessons
A documented limitation is a feature. Writing “do not run more than one worker” in the README protects the next engineer better than a clever workaround nobody understands.
In security code, defaults are decisions. Fail-closed secrets and an explicit proxy allow-list cost little and remove whole classes of mistakes.
Next iteration
- Move scheduled jobs out of process (external cron or leader election) so the API can scale horizontally.
- Measure precision of the risk scores against labelled sessions before using them to block anything automatically.