Short answer: treat identity linking as a state machine with a short-lived claim, inspect ownership before any write, and attach only after the second factor proves control. In a property-management system, that order keeps a stolen device fingerprint from silently becoming the key to a tenant's rent history.
The expensive thing is what you keep
The bill for this workflow is rarely the lookup itself. It is retention: raw device signals, every tentative match, screenshots from support, and audit records that nobody expires. A building portfolio with 18,000 active accounts can accumulate millions of fingerprint observations in a year if each login writes a full JSON blob. The dominant term is bytes retained multiplied by replication and index overhead, not the handful of authentication calls.
I keep a compact, salted fingerprint reference for the risk decision, a decision code, and an immutable event ID. The raw signal goes to a short retention tier, then disappears. That creates a real trade-off: an investigator loses the ability to replay an old browser's exact shape, but the breach radius and privacy burden shrink. Your mileage may vary when a regulator requires a longer evidentiary window; document that exception instead of retaining everything by default.
Here is the retention decision I want in a design review:
| Data | Default retention | Why | Cost of deleting sooner |
|---|---|---|---|
| Derived risk score and reason code | 90 days | Explain a recent challenge | Older support cases need a separate case ID |
| Salted device reference | 30 days | Detect a repeat device | Long-running fraud rings look less connected |
| Raw fingerprint attributes | 24 hours | Re-run a disputed score | Forensics must use upstream logs |
| Link and unlink events | Account lifetime + policy window | Prove who changed ownership | Storage and access controls stay permanent |
The uncomfortable part is intentional: retention is a security control, not just a storage setting.
Keep less.
What should resolve, inspect, and attach in a safe identity workflow?
Resolve means finding candidates without changing account state. A resolver can use a verified email, lease ID, or an existing session-bound identifier, but it should return a claim object with an expiry and a confidence reason. It must not return “the account” as if a fuzzy match were proof.
Inspect is a read-only ownership check. Compare the claim with a fresh authenticator, recent session context, and account status. For a tenant portal, that might be a passkey assertion or a one-time code delivered through a channel already on file. Device similarity is a signal; it is not possession.
Attach is the only mutating step. Make it idempotent with a unique pair such as (account_id, identity_provider, subject), and record who authorized it, when, and which policy version made the decision. If the pair already exists, return the existing link rather than creating a second row. If it belongs to another account, stop and ask for recovery; do not merge records in the login request.
I once treated a high fingerprint score as a green light in a test harness. The fixture reused a tablet image across two residents, and the score was 0.97; the resulting link crossed the household boundary. The fix was not a cleverer threshold. It was moving the ownership proof between resolve and attach, then making the database constraint enforce the same rule. That change also forced us to separate the support view from the authentication record, redact the raw attributes before they entered analytics, and replay expired claims in tests so a late mobile response could never attach an identity after its authorization window had closed.
That was the turning point.
A small state machine beats a clever callback
Keep transitions explicit: unresolved -> resolved -> inspected -> attachable -> attached, with expired and rejected as terminal states. Store the claim token server-side or encrypt it with an expiry; never trust an account ID echoed by a browser. The mutating handler should verify the token, re-check account status, and perform the insert in one transaction.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class LinkClaim:
account_id: str
subject: str
expires_at: datetime
policy_version: str
def can_attach(claim: LinkClaim, now: datetime, ownership_ok: bool) -> bool:
if now >= claim.expires_at:
return False
return ownership_ok
def create_claim(account_id: str, subject: str, policy_version: str) -> LinkClaim:
return LinkClaim(
account_id=account_id,
subject=subject,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
policy_version=policy_version,
)
The five-minute value is a policy example, not a universal constant. Measure completion time and challenge abandonment, then choose a window that limits replay without trapping a resident in a slow mobile flow. Rate-limit resolution and attachment separately so an attacker cannot turn cheap lookups into an account-enumeration oracle.
Failure modes that hide in production
The common failure is a check-then-write race: two requests inspect the same claim, both see no existing link, and both insert. A unique constraint plus a transaction is the boring, correct answer. Another is cache confusion, where a risk result for one device is served to another because the cache key omits the tenant or authenticator ID.
Watch for these signals in telemetry: resolve-to-inspect latency, inspect rejection rate, attach conflicts, expired claims, and unlink events by support operator. Log stable event IDs and reason codes, not raw fingerprints or one-time secrets. Alert on a sudden rise in cross-account conflicts; it often indicates an enumeration attempt or a broken resolver rule.
Do not silently retry an attach after an authorization failure. Retry transport failures only, with an idempotency key, and surface a new inspection requirement when the claim has expired. That distinction keeps an availability problem from becoming an authorization bypass.
No magic threshold.
Choosing friction deliberately
Low friction is appropriate for a known device with a recent strong authenticator and a low-value action such as viewing a maintenance notice. Require a fresh proof for payout changes, lease transfers, or a link that would join identities from different residents. The catch is that strict challenges annoy legitimate users on shared building kiosks; in that case, keep the kiosk session scoped and use a separate recovery path rather than weakening the attach rule.
| Situation | Safer action | User cost |
|---|---|---|
| New device, matching email | Inspect with passkey or code | One extra challenge |
| Shared kiosk fingerprint | Do not trust device continuity | Re-authentication per session |
| Identity already linked elsewhere | Reject and open recovery | Support involvement |
| High-risk account mutation | Fresh authenticator plus step-up | Noticeable friction |
Standards help define the floor, not the whole policy. The OWASP Authentication Cheat Sheet covers reauthentication, credential recovery, and session handling; your threat model still decides which account actions deserve a step-up. I am not sure a single numeric risk threshold can survive every property portfolio, because resident behavior, kiosk use, and fraud pressure differ; test the policy against those distributions before shipping.
Top comments (0)