DEV Community

ottoneumann8425
ottoneumann8425

Posted on

2026 Node.js Identity Ledger for Listing Account Login Methods Safely

Short answer: treat every login method as a revocable ledger entry, and refuse deletion when it would leave a logistics account without a verified recovery path.

That rule is more useful than a provider-specific checklist. A dispatcher may have a password, a corporate OIDC identity, and a phone passkey; the account page must show those identities without leaking secrets, then remove exactly one binding while preserving session revocation, auditability, and a usable recovery route. GDPR deletion is a data-lifecycle operation, not a button handler.

The invariant behind a multi-identity account page

I model an identity binding as an append-only record with a current status. The record has an internal identifier, a display label, provider type, last-used time, verification state, and a revocation timestamp. It does not contain an OAuth refresh token, password hash, or raw WebAuthn credential. Those belong behind narrower controls.

The account page reads a projection of that ledger. Listing is therefore a permissioned read: authorize the account owner, load bindings for that account, and return stable identifiers that cannot be confused with provider subject identifiers. A logistics operator should see “Acme OIDC” or “Passkey ending in …7F2A,” not an access token or an email address copied from an upstream claim.

The deletion path has four invariants:

  • A binding can be removed only by the authenticated account owner, with a recent step-up for a sensitive change.
  • At least one verified recovery method remains, unless the workflow is an intentional account erasure.
  • All active sessions and refresh-token families are revoked when policy requires it, and the event is durable before the UI reports success.
  • Retries are idempotent; the same request and idempotency key produce one audit event and one state transition.

The last point matters in payment and ledger work. A browser retry after a 504 must not turn one unlink into two contradictory events. The endpoint can safely return “already removed” when the binding is absent, while still proving who requested the transition.

Make that state visible.

How should listing and safely removing login methods work?

The critical path is deliberately boring. A read endpoint returns the projection; a command endpoint validates ownership, checks the recovery invariant, writes a compare-and-swap transition, revokes sessions, and emits an audit event. Keep the command separate from the read model so a stale page cannot silently authorize a destructive action.

Here is a framework-neutral Go sketch for the command service. The repository and revoker are interfaces so the same policy can run with a self-hosted database, a managed identity system, or a migration adapter.

package identity

import "context"

type Binding struct {
    ID         string
    AccountID  string
    Verified   bool
    RevokedAt  *int64
}

type Store interface {
    FindBinding(ctx context.Context, accountID, bindingID string) (Binding, error)
    CountUsableVerified(ctx context.Context, accountID string) (int, error)
    RevokeBinding(ctx context.Context, accountID, bindingID, idemKey string) (bool, error)
    AppendAudit(ctx context.Context, accountID, bindingID, idemKey string) error
}

type SessionRevoker interface {
    RevokeAll(ctx context.Context, accountID string) error
}

func RemoveLoginMethod(ctx context.Context, store Store, sessions SessionRevoker,
    accountID, bindingID, idemKey string, recentStepUp bool) error {
    if !recentStepUp {
        return ErrStepUpRequired
    }
    b, err := store.FindBinding(ctx, accountID, bindingID)
    if err != nil {
        return err
    }
    if b.RevokedAt != nil {
        return store.AppendAudit(ctx, accountID, bindingID, idemKey)
    }
    usable, err := store.CountUsableVerified(ctx, accountID)
    if err != nil {
        return err
    }
    if usable < 2 {
        return ErrRecoveryWouldBeLost
    }
    changed, err := store.RevokeBinding(ctx, accountID, bindingID, idemKey)
    if err != nil {
        return err
    }
    if changed {
        if err := sessions.RevokeAll(ctx, accountID); err != nil {
            return err
        }
    }
    return store.AppendAudit(ctx, accountID, bindingID, idemKey)
}
Enter fullscreen mode Exit fullscreen mode

In production, the state transition and outbox write should share one database transaction. Session revocation can be consumed from that outbox, with a monotonic event sequence so a delayed consumer cannot resurrect a refresh-token family. A 409 response is appropriate when a concurrent request changes the recovery count; the client should reload the projection and ask the user again.

Comparing designs at the migration boundary

Moving off a managed provider exposes assumptions that were previously hidden in callbacks. Record them in an architecture decision record before changing traffic.

Design Strength Failure boundary Best fit
Provider-hosted identities Fast rollout and mature protocol handling Export may omit provider-specific metadata Short migration window with low customization
Self-hosted identity ledger Full retention, audit, and deletion control Your team owns key storage, recovery, and incident response Regulated operations with a durable platform team
Adapter plus dual-write Gradual cutover and rollback Divergent writes unless reconciliation is continuous Large fleets where a flag-based migration is required

The adapter is not a permanent abstraction by default. It earns its keep when old and new identity IDs must coexist for a bounded period, with a reconciliation report that compares counts, verification states, and revocation timestamps. I would not choose dual-write for a small team that cannot page an owner for reconciliation; the operational surface is wider than the code suggests.

For the logistics scenario, deletion has two modes. “Remove this login method” preserves the account and its shipment history. “Erase this account” starts a GDPR workflow that anonymizes or deletes data according to retention obligations, then revokes every session. The UI must label these commands differently, require separate confirmation, and never infer erasure from an unlink request.

Failure modes worth testing before cutover

The most expensive bugs are race conditions, not malformed JSON. Test two simultaneous removals when only two verified methods exist; exactly one should win, and the loser should receive a conflict without changing state. Then force the browser to retry with the same idempotency key after the network drops between commit and response, checking that the audit stream contains one transition rather than two. Open the account page on a second device, revoke the binding on the first, and submit the stale form from the second; the command should reject the old version and return a fresh projection instead of silently deleting a different row. Finally, delay the session-revocation consumer and verify that a newly issued refresh token cannot outlive the revocation sequence. These tests exercise the boundaries that a happy-path integration test misses.

I also test provider subject reuse. If a carrier's OIDC tenant recycles a subject identifier, an old binding must not attach to a new person merely because the string matches; issuer, tenant, and an internal binding ID belong in the uniqueness constraint. Your mileage may vary on which claims are stable, so document the issuer contract and monitor unexpected changes.

Observability should expose counts and transitions, never credentials. Useful fields are account hash, binding ID, actor ID, policy decision, correlation ID, event sequence, and latency. Alert on an unusual spike in recovery-blocked attempts or on an outbox lag beyond the session revocation window. Keep audit records append-only and give compliance staff a read-only export with retention controls.

One practical boundary: an account page is not a credential manager. It can list safe metadata and initiate a policy-checked command; it should send users to a dedicated re-enrollment flow for passkeys or hardware keys. That separation limits the blast radius of an XSS bug and makes consent language clearer.

Decision rule for a safe account deletion rollout

Start with a shadow projection fed by existing identity events. Compare it with provider exports for at least one complete operational cycle, then enable listing for internal users, then unlinking behind a feature flag. Keep the old sign-in path read-only until reconciliation shows no unexplained bindings.

The recommended design is unsuitable when the product cannot operate an audit store, key lifecycle, and incident response rotation. In that case, stick with a hosted identity service and put a narrow, tested command adapter in front of it; accepting less control is safer than owning controls you cannot monitor. The deciding question is not which API is fashionable, but whether your team can demonstrate who removed which identity, why recovery remained possible, and when every session became invalid.

References

Top comments (0)