DEV Community

OwenSullivan9135
OwenSullivan9135

Posted on

Five Migration Rules for Listing and Safely Removing Multi-Identity Login Methods

Short answer: treat the multi-identity account page as a security-sensitive view over a server-owned credential state machine, and permit removal only after fresh authentication, an atomic last-method check, and a durable audit write. During migration off a managed authentication provider, show old and new login methods in one normalized inventory until every account has a verified destination credential.

The storage bill for this feature is made of credential metadata, indexes used to find identities, and retained security events. The dominant term is often unknowable from a design diagram: it depends on measured sign-in volume, event size, index amplification, and retention time. I'm not sure which term dominates in your system until those four values are measured. Use retained_bytes = events_per_day * average_event_bytes * retention_days as the first estimate, then add the datastore's measured index and replication factors. The useful change is usually to retain a compact, append-only security event instead of a raw provider response. Deliberately discard access tokens, password material, and full provider payloads; the cost is less forensic context when an integration dispute appears months later.

That trade is intentional.

What should a multi-identity account page list before removing login methods?

List methods from a canonical server-side record, never from browser state and never by passing provider tokens back to the page. Each row needs a stable opaque method ID, a user-facing type such as email and password, a masked identifier, verification state, creation time, and last-used time when that value is actually collected. The API should also return whether removal is currently allowed and a machine-readable reason when it isn't. It should not expose password hashes, provider subject identifiers that the user cannot act on, refresh tokens, recovery answers, or internal migration notes.

For a developer-tools account, the list can contain a verified email-and-password method imported into the new system and a legacy method still accepted during migration. Those are two credentials attached to one account, not two accounts that happen to share an email address. Email is mutable and can be recycled; it is display data, not the join key. The stable account ID owns the methods, while every method has its own lifecycle.

A response shape can stay small:

account_view = {
    "account_id": "acct_8f2c",
    "login_methods": [
        {
            "method_id": "lm_new_42",
            "type": "email_password",
            "display": "a***@example.com",
            "verified": True,
            "migration_state": "active",
            "removable": False,
            "blocked_reason": "last_active_method",
        },
        {
            "method_id": "lm_legacy_17",
            "type": "legacy_provider",
            "display": "Imported login",
            "verified": True,
            "migration_state": "retiring",
            "removable": True,
            "blocked_reason": None,
        },
    ],
}
Enter fullscreen mode Exit fullscreen mode

Do not let removable become authorization. It is display guidance based on a snapshot, and that snapshot can be stale before the user clicks Remove. The deletion command must repeat every security check against current server state. Short version: the list informs; the command decides.

Model removal as a state transition

The dangerous implementation counts rows, sees two, and deletes one. It fails when one row is unverified, disabled, pending migration, or concurrently removed in another tab. A safer invariant counts active, verified methods that can complete a sign-in now. Removal may proceed only if at least one such method will remain afterward. Recovery codes can be valuable, but don't silently count them as a normal login method unless the product explicitly defines and tests that behavior.

The transition also needs fresh authentication. OWASP recommends reauthentication for sensitive features and after risk events, plus invalidating sessions and rotating tokens after reauthentication. In this flow, a recent session alone is weak evidence: ask the user to prove control of an existing method, bind the proof to the account and requested action, and give it a short server-enforced lifetime. Don't accept an account_id supplied by the client as the authority for which account to mutate.

Treat these failure modes as ordinary inputs, not surprises:

Failure mode Required behavior
Two tabs remove different methods Serialize changes per account; one command succeeds and the other sees the new state
A stale page says removal is allowed Recompute eligibility inside the write transaction
The target method was already removed Return an idempotent result without creating a second audit event
Reauthentication belongs to another account Reject it without revealing method ownership
A migration job changes method state Lock or compare a version before committing
Audit persistence is unavailable Preserve the login method; do not perform an unaudited security mutation

Order matters. Verify the fresh-auth proof, load the target by (account_id, method_id), lock the account's active methods, recompute the post-removal set, mark the target removed, revoke sessions derived solely from it where session provenance is available, and append the audit event in the same durable unit of work. Send notifications after commit. A notification failure must not resurrect a credential, and a notification must never announce a deletion that later rolls back.

Make the write atomic and the audit small

The exact transaction API varies, so the storage boundary below is deliberately generic. Its contract is the important part: one account-scoped transaction, one versioned transition, and no secret values in the log.

from dataclasses import dataclass
from datetime import datetime, timezone


@dataclass(frozen=True)
class RemovalCommand:
    account_id: str
    method_id: str
    actor_session_id: str
    reauth_proof_id: str
    request_id: str


def remove_login_method(command, store, proofs):
    proof = proofs.consume(
        proof_id=command.reauth_proof_id,
        account_id=command.account_id,
        action="remove_login_method",
    )

    with store.account_transaction(command.account_id) as tx:
        previous = tx.find_result(command.request_id)
        if previous is not None:
            return previous

        target = tx.get_method(command.method_id)
        if target is None or target.removed_at is not None:
            return tx.record_idempotent_result(command.request_id)

        active = [
            method
            for method in tx.list_methods()
            if method.verified and method.can_sign_in and method.removed_at is None
        ]
        remaining = [method for method in active if method.method_id != target.method_id]
        if not remaining:
            raise ValueError("last_active_login_method")

        removed_at = datetime.now(timezone.utc)
        tx.mark_removed(target.method_id, removed_at)
        tx.revoke_sessions_issued_from(target.method_id)
        result = tx.append_security_event(
            request_id=command.request_id,
            event_type="login_method_removed",
            actor_session_id=command.actor_session_id,
            target_method_id=target.method_id,
            occurred_at=removed_at,
            reauth_proof_id=proof.proof_id,
        )
        return result
Enter fullscreen mode Exit fullscreen mode

There is a subtle race around proof consumption: consuming it before the account transaction prevents replay, but a later transaction conflict can leave the user needing to reauthenticate again. Consuming it inside a shared transaction is cleaner when both records live in the same transactional store. When they don't, use a single-use proof with an explicit attempt/result record and document the retry semantics. There isn't a universal answer because storage engines provide different atomicity boundaries; a failure-injection test is what resolves the choice.

Retain the compact event for the security review period your organization has actually approved. Keep the request ID, actor session ID, target method ID, timestamp, outcome, and reason code. Do not keep credential material just because storage is available. An append-only event helps answer who requested the change and what the server decided, but append-only isn't the same as tamper-evident; access controls, export policy, deletion policy, and independent integrity checks still need explicit decisions.

Migration changes the definition of safe

A migration off a managed provider creates a period in which two systems may authenticate the same person. The account page should read from the new canonical inventory even if a legacy adapter still verifies one method. Otherwise the page can claim that a destination password exists while the sign-in path still depends entirely on the old provider. Define migration states, make them monotonic where possible, and test every state transition against the last-active-method invariant.

State Can sign in? Can be removed?
Discovered legacy method No, until ownership is verified No
Legacy method accepted by adapter Yes Only when another active verified method remains
Destination email/password verified Yes Only when another active verified method remains
Removed No Already complete

Before changing traffic, run contract tests that create an account, attach a second method, list both, remove either one, reject removal of the final active method, replay the same request ID, and race two removal commands. Add failure injection between the credential update, session revocation, audit append, and notification enqueue. Observe counts by reason code rather than logging secrets: rejected final-method removals, expired reauthentication proofs, transaction conflicts, idempotent replays, and notification delivery failures tell an operator where the workflow is straining.

The catch is that this model is not suitable when legal or organizational policy requires centrally managed identities that users may not unlink themselves. In that case, render those rows as managed and route changes through the administrator's policy path. Also keep the managed provider longer when the destination cannot match required assurance, recovery, abuse controls, or audit retention. A clean account page is not a reason to weaken authentication.

Cutover should therefore depend on evidence: destination credentials have been verified, the canonical inventory agrees with both authentication paths, removal races pass under load, security events meet retention policy, and rollback does not restore a method the user intentionally removed. Once those conditions hold, stop retaining raw migration payloads and legacy lookup indexes according to the approved schedule. This reduces the dominant retained-data term, but it also makes a later reconstruction less detailed. Record that loss as a decision, not an accident.

References

Further reading

Top comments (0)