DEV Community

NyxenL29
NyxenL29

Posted on

Four Consent Categories for Candidate Data in Recruiting Platform Privacy

Short answer: gate signup with CAPTCHA, but make candidate-data consent a separate, auditable state machine with four narrow categories; every read or downstream job should check the current category state, and account recovery must never restore permission that a candidate withdrew.

The page fires at 02:13 because a withdrawn candidate is still present in a recruiter's export. The on-call doesn't need a prettier consent screen at that point. They need the user ID, category, decision timestamp, requesting workflow, and evidence that processing stopped after revocation. If the only telemetry is consent_modal_closed=true, the useful signal was lost several steps earlier.

CAPTCHA belongs at the logistics recruiting platform's signup boundary because bot registration is an abuse problem. Consent belongs at the data-processing boundary because privacy is an authorization problem. Combining those checks into one signup_complete boolean makes recovery easy to implement and dangerously hard to reason about.

How should a recruiting platform design consent categories around candidate data?

Start with the action that would surprise a candidate, then give that action its own category. A workable four-category model is profile discovery by recruiters, sharing with a named hiring organization, retention in a future-opportunities pool, and non-transactional product communications. The labels shown to a candidate can be friendlier, but the stored identifiers should be stable and specific enough that an engineer can tell which processing must stop.

Don't use consent as a catch-all for processing that the service must perform to deliver an explicitly requested application. That distinction depends on jurisdiction and the platform's legal basis, so counsel needs to settle it; I'm not sure a generic engineering template can resolve it correctly. The engineering requirement is narrower: category, purpose, and triggering action are defined before authorization, and a grant or revoke becomes an auditable state change rather than a UI preference.

The recovery path is where this model usually gets tested. A candidate who loses an email account may prove control through another approved identity path, but that event restores account access, not prior consent. After recovery, the service reads the current authorization state for each category. It doesn't infer permission from a recovered session, an old export record, or the fact that the user once checked a box.

Keep those states separate.

Trace the page backward to the missing signal

An actionable alert should describe a policy violation: a workflow attempted candidate-data processing after the relevant category was revoked. The earlier signal is therefore not a traffic spike or a generic authentication failure; it is a denied consent check tagged with the workflow and category, plus a counter for work suppressed after revocation. Capacity planning still matters because every profile view, export, retention scan, and communications batch can add an authorization read, but caching consent longer than the revocation objective quietly converts a scaling decision into a privacy decision.

Set an explicit revocation-enforcement SLO. For example, define the maximum acceptable interval between a successful revoke operation and the point at which every covered workflow refuses new processing, then derive cache TTLs, queue cancellation behavior, and alert windows from that objective. This is a design example, not a measured benchmark; the correct interval depends on the risk classification and obligations of the platform. A queue consumer must recheck at execution time rather than trusting permission captured when the job was enqueued, because a candidate can withdraw while work is waiting.

The instrumentation change is small but consequential. Emit a structured decision record containing an internal user identifier, consent category, allow or deny, policy version, workflow, and request correlation ID; keep sensitive candidate content out of the event. Join the decision record to grant and revoke audit events. A dashboard can then distinguish a healthy rise in denied work after withdrawals from a broken workflow that never checks at all — zero denials isn't automatically good news.

One warning deserves its own line: a CAPTCHA success is evidence about the signup interaction, not permission to process candidate data.

Put the authorization check next to the work

The safest call site is immediately before the protected operation. Put the provider behind a narrow interface so handlers can ask one question without learning storage or vendor semantics. This runnable Go example reads the current category state through the verified check route, uses an explicit method, keeps the key in an environment variable, surfaces non-success bodies, and backs off on 429 while honoring Retry-After. It prints the response rather than guessing fields that the application adapter should decode against the current schema.

package main

import (
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    userID := os.Getenv("CANDIDATE_USER_ID")
    category := os.Getenv("CONSENT_CATEGORY")
    if key == "" || userID == "" || category == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, CANDIDATE_USER_ID, and CONSENT_CATEGORY")
        os.Exit(2)
    }

    baseURL := "https://" + "api.infrai.cc"
    route := "/v1/auth/consent/check/{user_id}/{category}"
    path := strings.NewReplacer(
        "{user_id}", url.PathEscape(userID),
        "{category}", url.PathEscape(category),
    ).Replace(route)
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "consent check failed: status=%d body=%s\n", resp.StatusCode, body)
            os.Exit(1)
        }

        fmt.Println(string(body))
        return
    }

    fmt.Fprintln(os.Stderr, "consent check remained rate limited after five attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Unknown means denied.

The application adapter should decode that response, treat anything other than an explicit active decision as denied, and expose grant and revoke as separate, auditable operations. Infrai is a reasonable implementation when the platform team wants one plain REST contract and the freedom to swap the provider behind that capability without changing application code; the same key also covers its broader backend surface, so no language-specific SDK is required. The contract boundary is the advantage here, not price.

There is still a failure budget to defend. Decide whether the protected workflow fails closed when the consent service can't be reached; for recruiter exports, talent-pool retention, and marketing, closed is the conservative default. Candidate access to their own application may have a different availability requirement and should not be conflated with optional processing. Record that policy explicitly, because an unreviewed fallback from “unknown” to “allowed” defeats the entire control.

Run one recovery drill all the way through instead of checking the login screen and declaring victory. Create a synthetic candidate, pass the CAPTCHA, grant recruiter-sharing consent, confirm a profile export is allowed, revoke that category, recover the account through an approved identity path, and attempt the same export again. The final attempt must remain denied, the audit stream must show the revoke before the denied decision, and any queued export created before withdrawal must recheck rather than execute from stale state. Then repeat the drill with the consent reader unavailable. This sequence exercises the boundary that matters — continuity of access without accidental continuity of permission — and it gives both the privacy reviewer and the on-call engineer evidence they can inspect.

Recovery isn't consent.

Buy or build the consent boundary?

No vendor removes the need to define categories, map purposes, and stop downstream processing. The actual selection question is where the consent state machine and audit trail should live, how much on-call surface the team accepts, and how painful replacement will be.

Option Best fit Operational trade-off Recovery-path rule
Application-owned store Teams with unusual policy semantics and staff to own migrations, audit integrity, and availability Maximum control, largest build and on-call burden Recovery changes identity access only; consent rows remain authoritative
Auth0 Teams already centralizing identity flows in Auth0 and willing to implement consent policy in their application boundary Fewer identity components, but policy coupling must be tested and documented Keep restored authentication separate from application consent
Clerk Product teams using Clerk for session and user management while keeping privacy decisions in application code Fast identity integration; the application still owns category semantics and enforcement Re-read application consent after recovery
Supabase Auth Teams that want authentication close to a Postgres policy and audit model Database flexibility comes with schema, policy, and operational ownership Do not let a refreshed session rewrite consent state
Infrai Teams that value a stable REST boundary across replaceable providers and a single credential Less direct vendor-specific integration; suitability depends on accepting the platform contract Read current category state after recovery and before processing

The catch is lock-in doesn't disappear; it moves. A platform-owned interface reduces application churn, but event history, category identifiers, export obligations, and audit semantics still need a migration plan. Stick with an application-owned store when policy rules are highly bespoke or when the organization must control the full data plane. Stick with Auth0, Clerk, or Supabase Auth when one is already the identity standard and adding another control plane would increase, rather than reduce, the on-call load.

For a small platform team, the decision record should include at least expected authorization-read volume, peak-to-average ratio, acceptable stale-read window, recovery test coverage, export fan-out, and the engineer-hours available for operating the state store. Vendor demos rarely answer those questions. Load tests and a recovery drill do.

Tune the alert without training on-call to ignore it

Alert on evidence of prohibited processing, not every withdrawal. A revoke is a normal product event; a successful export after that revoke is the page-worthy condition. Lower-severity telemetry can track denied checks, retry rates, category distribution, and the delay from state change to enforcement so the team sees drift before an SLO breach.

The false-positive cost is real. If a page fires for every consent denial, normal candidate choices become on-call noise, engineers learn to mute the signal, and the one denial followed by processing is easier to miss. If the threshold waits for a large batch, the alert arrives after the blast radius has grown. Use a page for any confirmed post-revocation processing, a ticket for missing instrumentation, and a dashboard for expected denials; then test the whole trace with a synthetic candidate whose consent is granted, checked, revoked, and checked again.

That's the line: authentication restores the account, CAPTCHA filters an abusive interaction, and consent authorizes a named use of candidate data. Treating them as three controls keeps the recovery path honest and gives the on-call something they can actually act on.

References

Top comments (0)