The page fires at 02:14: signup completion has fallen below 60%, while the registration queue is full of disposable addresses. Every authentication system needs account lifecycle boundaries, and the on-call now needs to know which boundary this request is allowed to cross.
Short answer: define explicit lifecycle boundaries for registration, verification, activation, suspension, recovery, and deletion, then make the CAPTCHA decision one state transition rather than a boolean sprinkled through handlers. The boundary should be observable, idempotent, and reversible where policy permits.
That distinction matters in an e-commerce signup flow. A bot can pass a challenge and still fail email verification; a real shopper can fail a challenge because a mobile network changed IPs. Treating both outcomes as “auth failed” hides the signal we need. I have been paged for missed jobs and duplicate deliveries, so I look for the same operational smell here: a retry that silently creates a second state instead of replaying the first decision. The queue, database, and challenge provider each report a different clock, and the useful incident timeline is the one that joins them by attempt key. Without that join, a delayed callback looks like a new signup, a duplicate delivery looks like a fraud spike, and an engineer may raise the CAPTCHA threshold while the real problem is a lost state transition.
Keep it explicit.
Which account lifecycle boundaries should an authentication system define?
Start with a small state machine. pending_captcha means the request has a risk decision but no account session. pending_verification means the challenge passed and a verification token was issued. active means the account can sign in. suspended blocks sign-in while preserving evidence. recovery is a controlled path back to an active account, and deleted is a terminal business state with a retention rule attached.
Do not let a client submit an arbitrary next state. The server derives the transition from an event, policy version, and current row version. A unique idempotency key on the signup attempt makes a browser retry safe when the first response was lost.
package lifecycle
import (
"context"
"errors"
)
type State string
const (
PendingCaptcha State = "pending_captcha"
PendingVerification State = "pending_verification"
Active State = "active"
Suspended State = "suspended"
Recovery State = "recovery"
Deleted State = "deleted"
)
var ErrInvalidTransition = errors.New("invalid account transition")
type Event struct {
Name string
AttemptKey string
Policy string
}
type Store interface {
Transition(ctx context.Context, accountID string, from, to State, event Event) error
}
func Apply(ctx context.Context, store Store, accountID string, from State, event Event) error {
to, ok := nextState(from, event.Name)
if !ok || event.AttemptKey == "" || event.Policy == "" {
return ErrInvalidTransition
}
return store.Transition(ctx, accountID, from, to, event)
}
func nextState(from State, event string) (State, bool) {
switch {
case from == PendingCaptcha && event == "captcha_passed":
return PendingVerification, true
case from == PendingVerification && event == "email_verified":
return Active, true
case from == Active && event == "risk_review":
return Suspended, true
case from == Suspended && event == "review_cleared":
return Recovery, true
case from == Recovery && event == "identity_reverified":
return Active, true
default:
return "", false
}
}
The storage operation must compare the current state and row version in one transaction. If two workers process the same captcha_passed event, one commits and the other observes the already-applied attempt key. That is the useful kind of duplicate: visible, harmless, and measurable.
What should the CAPTCHA boundary measure, and what should it never decide?
CAPTCHA is an abuse control, not an identity proof. Its output should feed a risk policy that can require another factor, delay activation, or send the attempt to review. It should not decide whether an email address belongs to a person, and it should never be the sole reason to erase an account.
Instrument the boundary before tuning it. Count challenge issued, challenge passed, challenge expired, verification completed, and activation latency. Break those metrics down by device risk and network category, but avoid logging raw email addresses or challenge answers. The earlier page in this incident should have fired on a ratio of pending_captcha to pending_verification, not only on completed signups; that ratio catches a provider timeout before the queue becomes an outage.
False positives have a price. A threshold that blocks a shared apartment IP may reduce bots while also rejecting legitimate shoppers during a sale. Keep a bounded appeal or alternate verification path, and sample decisions for review. Your mileage may vary across regions; mobile carrier NAT makes a single IP a weak identity signal.
Run the lifecycle like a production workflow
Every event needs a correlation ID, policy version, actor class, and expiry. Store those fields with the transition, not only in a log stream that may be sampled. Alerts should distinguish a provider timeout from a policy denial: the first calls for dependency handling, the second may indicate an attack or a bad rule.
I once assumed a 200 response meant the signup step was complete. A worker restart proved otherwise: the response left the browser, but the cursor update did not. We now commit the transition and its idempotency key together, then reconcile counts from the database against queue acknowledgements. The check is boring. It is also what tells an on-call engineer whether to replay work or investigate abuse.
Recovery and deletion deserve their own runbooks. Suspension should preserve sessions and tokens in a revocable inventory; recovery should revoke them before issuing a new one. Deletion should define what is removed immediately, what is retained for fraud or tax obligations, and who can authorize an exception. A system that has only active and disabled cannot express those obligations safely.
The catch is that a custom state machine is not suitable when a small team cannot operate key rotation, audit retention, and 24-hour incident response. Stick with a managed identity workflow when those controls are a contractual requirement, and place the CAPTCHA policy at a boundary you can observe. Choose the simpler design when your threat model is low and the cost of a false challenge outweighs automated abuse.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Automated Threats to Web Applications: https://owasp.org/www-project-automated-threats-to-web-applications/
- RFC 6819, OAuth 2.0 Threat Model and Security Considerations: https://www.rfc-editor.org/rfc/rfc6819
- NIST SP 800-63B, Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
Top comments (0)