DEV Community

ValtorMist7692
ValtorMist7692

Posted on

Signup Captcha Lockouts: A 5-Step Path After Identity Removal

Short answer: treat identity removal as a state transition, not a delete. Preserve a recovery principal, revoke sessions deliberately, and make the next login prove which credential is still valid. In a fintech signup flow, this prevents a bot-control change from turning a legitimate customer into a permanently locked account.

The failure pattern is easy to miss. A user unlinks an identity provider, the account row remains, and the last-login-method pointer still names the removed method. The captcha gate then rejects the next attempt before password recovery can run. Monitoring reports a normal stream of failed authentication, while support sees “the account exists but nothing works.”

I start with one invariant: every active account must have at least one reachable recovery path before an identity is removed. If that cannot be proven, the unlink operation is a controlled lockout, not a successful deletion.

That distinction matters.

What should the login state machine do after identity removal?

Model the transition explicitly. linked -> pending-removal -> unlinked is safer than deleting a row and hoping downstream caches notice. During pending-removal, block the destructive action unless a second factor, verified email, or staffed recovery route is present. At unlinked, clear the last-login-method pointer, revoke sessions that depended on the removed identity, and retain an audit event with actor, timestamp, and reason.

The captcha check belongs after account lookup and risk scoring, but before issuing a session. It must not be the only gate in front of recovery. A useful response contract is intentionally vague to an attacker (“we could not verify this attempt”) while an internal event records identity_removed, recovery_available, and the selected challenge.

Here is the guard I use in a service boundary. It is deliberately boring: the hard part is the invariant and the transaction, not clever cryptography.

type Account struct {
    ID                string
    LastLoginMethod   string
    RecoveryMethods   []string
    SessionsRevokedAt time.Time
}

func RemoveIdentity(a *Account, method string, now time.Time) error {
    if a.LastLoginMethod == method && len(a.RecoveryMethods) == 0 {
        return fmt.Errorf("removal denied: no recovery principal")
    }
    if a.LastLoginMethod == method {
        a.LastLoginMethod = ""
    }
    a.SessionsRevokedAt = now
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The database update and identity-provider unlink must be coordinated with an outbox or equivalent retryable record. Otherwise, a timeout can leave the provider unlinked while the local pointer still selects it. That is the split-brain version of this incident.

How do you diagnose account lockout without guessing?

Start from one account ID and build a timeline, in UTC, across four stores: identity links, account status, session metadata, and risk or captcha decisions. Correlate by a request ID; never use an email address as the only key. The useful question is not “did captcha fail?” but “which state made a valid recovery impossible?”

A five-step runbook keeps the investigation bounded:

  1. Confirm the identity-removal event and its actor.
  2. Compare last_login_method with currently linked methods.
  3. Check whether a recovery method was verified before the unlink.
  4. Inspect session revocation time against token issue time and cache TTL.
  5. Replay a recovery attempt in a staging account with the same risk signals.

One production review I ran found a 15-minute cache retaining the removed method. The database was correct; the edge decision was stale. We reconstructed the timeline minute by minute: at 09:02 UTC support approved the unlink, at 09:03 the provider event arrived, and at 09:04 the cached method was still selected for a signup retry. The captcha service did exactly what it was asked to do, yet the user had no valid path to recovery because the stale selector ran first. That distinction changed the fix from account restoration to cache invalidation and an event-ordering test. Your mileage may vary when a third-party risk engine owns part of the decision, so capture its decision ID rather than inferring from HTTP status alone.

The SLO should measure recovery completion, not just login success. Alert when the ratio of accounts entering unlinked without a verified recovery principal exceeds zero, and track the 99th-percentile time from a support-approved recovery request to a usable session. A low captcha error rate can coexist with a terrible recovery SLO.

Which controls belong in the service, and which belong in a managed provider?

The migration question is about ownership. A managed identity service can absorb protocol maintenance and abuse signals; app-owned state gives you sharper control over transaction boundaries, data retention, and incident replay. Neither removes the need for an account state machine.

Decision area Managed component App-owned component
Protocol and key rotation Less on-call work; vendor schedules changes Your team tests and rotates keys
Identity unlink semantics Often constrained by provider lifecycle Exact transitions and audit fields are yours
Captcha and risk signals Fast access to specialized scoring More tuning, data, and pager load
Portability Export and migration limits must be verified You carry integration and compliance work
Failure replay Depends on provider event history You can retain deterministic event fixtures

The catch is that moving everything in-house is not suitable when your team cannot staff abuse response, key rotation, and 24/7 recovery. Stick with a managed boundary when its lifecycle semantics are documented and its export path passes a restore drill. Choose app ownership when a regulated workflow requires fields or retention guarantees the provider cannot expose.

How can tests and observability stop the next lockout?

Write contract tests around transitions, not screens. The minimum matrix includes unlinking a non-last method, rejecting unlink of the last method, replaying an old session, captcha timeout followed by recovery, and duplicate unlink events. Property-based tests can assert that an account in active or unlinked always has either a verified recovery principal or an explicitly suspended status.

Instrument counters for identity_removal_denied, recovery_started, recovery_completed, captcha_challenged, and captcha_passed. Add a trace span around the decision that chooses the last-login method. Redact tokens and challenge answers; OWASP recommends avoiding account-enumeration signals and recording authentication failures for detection.

During rollout, canary the new transition for internal accounts, then a small percentage of signups. Keep a reversible feature flag until you have observed at least one full session-expiry window and a support-led recovery drill. Five minutes of synthetic testing is not evidence that a 30-day refresh token behaves correctly.

The practical rule is simple: an identity can disappear, but the account’s path back in cannot.

References

Top comments (0)