Skip to content
Suleiman Francis

Device Intelligence · SeamHealth Group

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.

Role
Lead backend engineer
Timeline
Jul 2026 — Aug 2026
Team
Small team; I wrote the initial implementation and most of the commits.
Platform
Backend service · HTTP API
Stack
  • 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

  1. Client
    • Browser fingerprint + location report (Clientshot Pulse)
  2. API
    • FastAPI routes
    • Pydantic v2 validation
    • /health
  3. Application
    • Services
    • Risk engine
    • Device graph
  4. Workers
    • APScheduler: similarity, merging, risk updates, session expiry
  5. Storage
    • SQLAlchemy 2 + Alembic
    • SQLite locally → Postgres (RDS) in production
Simplified. Detection signals are intentionally not listed.
  • 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

Problem
Four recurring jobs need to run, but adding a queue or cron service makes local setup heavier.
Options
  1. External cron calling job endpoints
  2. A task queue with separate workers
  3. APScheduler inside the API process
Trade-offs
In-process scheduling needs no extra infrastructure, but every replica runs every job — so the service can’t scale horizontally without duplicating work.
Decision
Use APScheduler in-process, pin gunicorn to one worker, and document the single-process constraint in the README.
Reason
It fits current traffic, and an explicit, written limit is safer than a hidden one someone discovers by scaling out.
Result
Simple to run and deploy, with the migration path written down: move jobs out of process before adding replicas.

02Fail closed on secrets

Problem
The secret key signs visitor cookies and CSRF tokens, and a development default had to exist for local runs.
Options
  1. Warn when the default key is used
  2. Refuse to start in production with the default key
Trade-offs
Refusing to boot can block a deploy; a warning can be missed and leave tokens forgeable.
Decision
The app refuses to start when ENVIRONMENT=production and the committed default key is in use.
Reason
For a security service, a failed deploy is better than a running service with forgeable tokens.
Result
Misconfiguration surfaces at deploy time, not in an incident.

03Trust forwarded IPs only from known proxies

Problem
Behind a proxy, the real client IP arrives in X-Forwarded-For — a header any client can forge.
Options
  1. Always trust X-Forwarded-For
  2. Never trust it
  3. Trust it only from configured proxy addresses
Trade-offs
Always trusting it lets attackers choose their IP; never trusting it loses the real IP behind a load balancer.
Decision
Honour X-Forwarded-For only when the request comes from an address in TRUSTED_PROXY_IPS.
Reason
IP-based signals are only worth having if they can’t be set by the client.
Result
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 16Measured
Including the initial implementation, from git history.
Test files
14Measured
Unit and integration.
Fraudulent votes prevented
16 of 16Measured
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.