DEV Community

ChrysostomHayes8537
ChrysostomHayes8537

Posted on

Property Password Identity Linking Workflow 2026: Inspect Before Safe Attachment

Short answer: In a property-management password recovery flow, resolve the external identity first, inspect its ownership and assurance second, and attach it only through an explicit, logged state transition. A recovery request should never become an account merge just because two records share an email address.

That decision rule matters because a tenant portal is a useful target for automated abuse. A stolen mailbox, a recycled phone number, or a typo in a landlord's spreadsheet can turn a convenient linking feature into unauthorized access to leases, payment history, and maintenance keys. The workflow below keeps the account-binding question separate from the reset-token question, so an auditor can reconstruct what happened without trusting a fuzzy match.

What should identity linking resolve, inspect, and attach safely?

Treat the operation as four states: received, resolved, inspected, and attached (or rejected). Each transition has an input, a decision, and an audit record. The external provider's subject identifier is the stable candidate; a display name or normalized email is evidence for review, not proof of sameness.

For a forgotten password, the user first proves control of a recovery channel that is already associated with the property account. Only then does the service resolve the identity assertion. The inspection step checks whether that identity is already attached, whether the target account has another usable sign-in method, and whether policy allows a new attachment. A failed match remains a failed match. Do not silently create a second tenant account and do not auto-merge on partial profile data.

Stop there.

Here is a small TypeScript boundary that makes the ordering visible. It uses the identity operations as separate calls; your service can put an authorization gateway in front of them and persist the audit event alongside each result.

type Identity = {
  subject: string;
  issuer: string;
  email?: string;
  emailVerified: boolean;
};

type LinkDecision =
  | { state: "attached"; userId: string }
  | { state: "rejected"; reason: string };

async function linkAfterRecovery(
  recoveryUserId: string,
  assertion: string,
  requestId: string,
): Promise<LinkDecision> {
  const resolved = await api<Identity>("POST", "/v1/auth/identity/resolve", {
    assertion,
    requestId,
  });

  const existing = await api<{ userId?: string; attached: boolean }>(
    "POST",
    "/v1/auth/identity/get",
    { issuer: resolved.issuer, subject: resolved.subject },
  );

  if (existing.attached && existing.userId !== recoveryUserId) {
    await audit("link_rejected", { requestId, reason: "already_attached" });
    return { state: "rejected", reason: "identity belongs to another user" };
  }

  if (!resolved.emailVerified) {
    await audit("link_rejected", { requestId, reason: "unverified_email" });
    return { state: "rejected", reason: "assurance is insufficient" };
  }

  await attachIdentity(recoveryUserId, resolved, requestId);
  await audit("link_attached", { requestId, userId: recoveryUserId });
  return { state: "attached", userId: recoveryUserId };
}

async function api<T>(method: "POST", path: string, body: unknown): Promise<T> {
  const response = await fetch(`https://identity.internal${path}`, {
    method,
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`identity request failed: ${response.status}`);
  return response.json() as Promise<T>;
}

async function attachIdentity(userId: string, identity: Identity, requestId: string) {
  // The write is idempotent on (issuer, subject) and protected by a transaction.
  await persistLink({ userId, identity, requestId });
}

declare function audit(event: string, fields: Record<string, string>): Promise<void>;
declare function persistLink(input: unknown): Promise<void>;
Enter fullscreen mode Exit fullscreen mode

The example deliberately has no “find by email, then merge” shortcut. The issuer plus subject pair is the lookup key, and the uniqueness constraint belongs in the database as well as in application code. If two browser tabs race to attach the same identity, one transaction wins and the other records a rejection; a retry must return the same decision, not create another relationship.

How do bot controls change the recovery and attachment path?

Rate limiting is necessary but not sufficient. Apply independent limits to account lookup, recovery-message issuance, assertion resolution, and attachment attempts. Return the same outward response for an existing and a missing tenant record, and add a small, adaptive delay after repeated failures. This reduces enumeration without making every legitimate resident wait.

Use a single-use, short-lived reset capability bound to the recovery transaction. Bind it to the intended user, an issuer, and a nonce; reject reuse, issuer changes, and attempts to attach an identity after the recovery transaction has expired. A CAPTCHA can be one signal, but it should not be the only gate for a screen-reader user or a property manager working from a shared office network.

The audit event should include request ID, actor type, user ID (when known), issuer, subject hash, policy decision, and timestamp. Do not put raw reset tokens or assertion strings in logs. Security staff need enough data to correlate a burst of attempts, while tenants should not have their credentials copied into an analytics pipeline.

Where do linking policies fail in real property data?

Property records are messy. A unit can have co-tenants, a management company can migrate domains, and a former resident's email can be reassigned. Matching alex@example.com to an account named Alex is not an identity proof. Require an explicit authenticated action, and send a notification to existing login methods when a new identity is attached.

Unlinking needs the same care as attaching. Before removing an identity, inspect whether the user still has a password, another verified provider, or an administrator-approved recovery route. If no usable method remains, require a stronger recovery review instead of leaving an account stranded. Multiple identities per user are fine; one identity attached to two users is not.

I once started a test with a clean fixture and missed the race entirely. Two parallel requests both read “not attached” and both returned success; the audit log looked tidy until the database showed duplicate relationships. The fix was boring: a unique key, a transaction, and a test that launches both requests with the same requestId. Boring is good here. The test now also varies timing around the commit, retries the losing request, checks that the second response is deterministic, and verifies that an operator can explain the result from the event trail without reading debug logs. That catches a subtle failure: a link can be unique in the final table while two reset sessions briefly grant different answers. The database constraint closes the hole, but the state machine and audit record make the closure visible.

Choosing an implementation boundary

Keep the identity service responsible for resolving and inspecting assertions, while the property application owns tenant policy, recovery state, and audit retention. That boundary lets a team replace an upstream identity provider without rewriting lease or payment logic. It also makes a provider outage a clear “try again later” decision rather than an accidental account creation path.

A self-hosted library may suit a team that needs full control of storage and traffic shaping. A hosted identity platform can reduce operational work, but check its export format, recovery hooks, rate-limit semantics, and ability to represent one user with multiple identities. The right choice is the one whose failure and deletion behavior you can test, document, and audit.

The catch is that this workflow is not suitable when your product needs anonymous, instant account merging or offline linking with no trusted recovery channel. In those cases, keep identities separate and ask the user to complete a higher-assurance support process; a faster merge is not worth an irreversible authorization mistake. Your mileage may vary with local tenancy law and retention requirements, so have counsel confirm the audit period before setting a deletion job.

Before shipping, walk through the checklist as prose: verify that every resolve is followed by an inspection, every attachment has an idempotency key, and every rejection is observable without leaking whether an account exists. Test replayed tokens, mismatched issuers, duplicate subjects, concurrent tabs, deleted users, and a provider returning a valid assertion for an already attached identity. Then rehearse an auditor asking, “Who approved this link, on what evidence, and what other login method existed at the time?” If the answer requires a database archaeologist, the workflow is not ready.

References

Top comments (0)