All posts

BaoHost / Engineering

Building a fraud detection system from scratch

A practical build order based on the system I built for BaoHost: collect useful events, keep evidence, connect related accounts, and make decisions reviewable.

1. Start with the abuse you need to handle

For BaoHost, fraud detection is primarily about account abuse: repeated registrations to consume free resources, attempts to evade restrictions, and activity that needs investigation. It is not a payment fraud scoring product. Defining that scope makes the first version much easier to build.

This is the build order I would use if I were starting again, based on the Laravel services in the BaoHost panel. It is an architectural walkthrough rather than a claim that the system was originally built in exactly this sequence.

Before writing a detector, define the possible outcomes: no action, hold for review, dismiss a flag, or suspend an account. Decide who can take each action and what evidence they need. A suspicious observation and a confirmed policy violation should not be the same database state.

2. Collect events before inventing a score

Start with the events the application already understands: registration, authentication, session activity, and relevant account actions. Store the account reference, event type, timestamp, and the request context needed for an investigation. My panel already has activity logs, session records, and known-device records that the analysis services can query.

Keep the source of each observation clear. A timestamp recorded by the backend and a metric submitted by a browser do not have the same trust level. Client-supplied values can be missing or manipulated, so they should not silently become verified facts.

For a new implementation, collect the minimum useful information first. Restrict staff access, choose retention periods, and avoid copying credentials, tokens, or unrelated request bodies into security logs. Those are design requirements to establish, not guarantees that follow from adding a fraud service.

3. Give flags their own data model

My panel uses UserFlag records rather than storing the entire investigation in a boolean on the user. A flag has a type, severity, supporting details, and resolution fields. This lets staff see what triggered a review and who resolved it.

The outline below is a simplified description of that model, not a database migration. A complete migration also needs the appropriate types, relationships, and indexes for the database you use.

Repeated detections should update a useful case rather than burying staff in identical rows. FraudDetectionService aggregates repeated unresolved flags and records occurrence information with first-seen and last-seen timestamps. In a new version, I would also make concurrent deduplication explicit with a stable key and database-level protection.

Minimal flag record — schema outline
user_flags
  id
  user_id
  type
  severity
  details           JSON evidence and occurrence information
  is_resolved
  resolved_by       nullable staff user reference
  resolved_at       nullable timestamp
  created_at
  updated_at

4. Write a few explainable detectors

I would begin with a small set of deterministic checks that create flags and explain the observation behind them. Avoid a single opaque risk score before there is enough reviewed data to understand what its numbers mean.

In my panel, FraudDetectionService handles checks during registration and checks against later activity. DetectFraudCommand also scans active accounts on a schedule. These are complementary paths: registration catches an immediate concern, while a later scan can see relationships that were not present at signup.

The implementation also has policy-based registration denial paths that can act without AI. They need to be distinguished from the AI-assisted workflow. For someone building a new system, I would initially run new rules in review-only mode and examine legitimate shared-network cases before allowing enforcement.

5. Assemble context around the account

Once individual flags exist, add a service that gathers the information a reviewer otherwise has to find manually. UserClusterAnalysisService combines account history, session and device context, related-account observations, and existing flags into one view.

Keep the reason for every relationship. An association based on a shared network is not equivalent to proof that two accounts belong to one person. A cluster is a candidate set for investigation, not a group verdict.

Network enrichment is useful context too. My panel separates geolocation and VPN or hosting-network lookups into services. A new implementation should preserve lookup failures and unsupported inputs as unknown, rather than treating a missing result as evidence that a connection is safe.

6. Move expensive analysis into jobs

Do not turn every ordinary page request into a full account investigation. My panel uses AnalyzeFraudClusterJob for queued analysis, along with scheduled scans. Some registration paths also request immediate evaluation, so queueing is not the only execution path.

For a new build, give jobs bounded execution time and deliberate retry behavior. Deduplicate repeated requests for the same investigation, and cache enrichment or analysis where it makes sense. The current code has dispatch guards in one detection path and caches cluster evaluations; that is not a claim that every possible dispatch path is deduplicated.

Make the age of evidence visible. If you cache a result, keying it only by the account or cluster membership may miss a change in behavior. A useful improvement is to tie cached decisions to an evidence version and invalidate them when relevant events change.

7. Add AI after the review workflow exists

AI becomes useful when there is already a bounded case to analyze. FraudAiDecisionService assembles account context and requests a structured recommendation from Azure. It does not replace the event store, the rules, or the staff interface.

The result includes a recognized decision, an explanation, indicators, and a confidence value. Validate the result before it reaches the action layer. A model-generated confidence number is not a measured probability of abuse, and an eloquent explanation still needs to match the evidence.

In this AI-assisted path, a failed request or unusable response falls back to manual review. The diagram shows that path; the separate policy-based registration checks described earlier are not represented here.

Account review, at a glance

AI recommends. Application code controls the action.

8. Put enforcement behind application checks

Detection code should describe a concern. Enforcement code should decide whether an action is permitted now. My panel separates those responsibilities through FraudAutoSuspensionService, which checks candidate accounts, configured limits, and the conditions for an automated suspension.

The service uses transactions and row locks around account decisions, and checks for newer staff resolutions before applying an older assessment. That matters when a queued job finishes after a reviewer has already handled the case.

The pseudocode below illustrates the boundary I would preserve. These helper names are illustrative, not methods you can copy directly into an application. Re-running the same job should not repeat a suspension, send duplicate notifications, or produce contradictory outcomes.

Applying a recommendation — illustrative pseudocode
decision = analyze(evidence)

if decision is invalid or unavailable:
    keep_for_manual_review()
    return

targets = restrict_to_reviewed_candidates(decision)

transaction:
    lock_target_accounts(targets)
    reload_current_state()
    skip_newer_staff_resolutions()

    if automated_action_is_permitted(decision):
        apply_action_once()
    else:
        keep_for_manual_review()

    record_decision_and_outcome()

9. Build the staff and user paths together

A useful review screen shows the account, the original flag, related observations, previous resolutions, and the reason for the current recommendation. In my operations interface, staff can inspect and resolve flags, and those actions are written to an audit log.

Pending review also needs backend enforcement. CheckPendingReviewMiddleware checks unresolved review flags on the routes where it is applied; hiding a button in the browser would not provide that boundary. The panel can show a pending-review page or return an API error instead.

Appeals are part of the same system. Keep the original evidence and resolution history available when a user disputes a decision. My panel has appeal analysis and triage services, but a model summary should not erase the original record or turn user-written instructions into permission to approve an account.

10. Evaluate before expanding automation

For a first release, I would collect flags, review cases manually, and compare proposed actions with staff outcomes. Then I would use dry runs before enabling a narrowly scoped automated action. The code has dry-run support and synthetic AI test commands; their existence alone is not evidence of production accuracy.

Include ordinary users in the evaluation, not just obvious abuse examples. Shared household networks, changing devices, missing telemetry, provider timeouts, duplicate jobs, and a staff decision made while a job is running all deserve explicit cases.

Track false positives, review waiting time, failed analyses, and decisions overturned after appeal. This article does not claim measured results for those metrics. They are the feedback I would use to decide whether an automation rule is helping.

The first version can stay small

The first useful release does not need a model. It needs a reliable event trail, a flag record with evidence, a handful of explainable checks, a staff review page, and a way to record the outcome. Add relationship analysis and automation when those foundations are useful to the people reviewing cases.

That is the lesson I take from building this system for BaoHost: the work is not finished when a detector finds something suspicious. The system also has to handle duplicates, uncertainty, failed services, concurrent decisions, and users who need a second look.

The examples here describe architecture and simplified implementation patterns. They do not publish live detection thresholds, internal prompts, or real user records.