OTP Flows Need Abuse Controls, Not Just Code Verification
Jun 03, 2026
API DesignArchitectureReliabilitySaaSWorkflow Design
OTP security is not only about generating and checking a code. Real protection comes from stateful workflow design: expiry, one-time use, attempt limits, resend throttling, layered rate limits, suspicious-pattern detection, and backend-enforced abuse controls.
A recent password-manager incident is a good reminder of something many systems still get wrong:
OTP should not be treated as only:
Generate code.
Send code.
Check code.
Login done.
That is the happy path.
But authentication systems are not attacked through the happy path. They are attacked through repeated attempts, edge cases, weak expiry rules, missing throttling, device registration flows, password reset flows, and inconsistent state handling.
Dashlane recently described a brute-force attack against certain user accounts where attackers targeted the device registration flow and submitted a large volume of automated requests against 2FA verification. Dashlane said fewer than 20 personal-plan encrypted vaults were copied, and that there was no evidence its internal systems were impacted. Dashlane security advisory.
The lesson is not “OTP is bad.”
The lesson is:
OTP is not just a code. OTP is a security workflow.
And every security workflow needs abuse controls.
Context and operating problem
Many teams design OTP flows like this:
1. User enters phone number or email.
2. System generates OTP.
3. System sends OTP.
4. User enters OTP.
5. System checks OTP.
6. User is logged in.
This looks simple.
But real attackers ask different questions:
Can I try 100 OTPs in one minute?
Can I request OTP again and again?
Can I keep using the old OTP after a new one is generated?
Can I attack the same account from multiple IPs?
Can I attack multiple accounts from the same device?
Can I register a new device using a weak verification flow?
Can I use automation to test every possible 6-digit code?
These are not theoretical design questions. They are exactly the kind of edge cases that decide whether an authentication feature is secure or not.
OWASP recommends protecting authentication flows against automated attacks with controls such as login throttling, account lockout, and monitoring. NIST also requires rate limiting for short authentication secrets such as OTPs, and says time-based OTPs need a defined lifetime.
Common mistake: only validating the OTP value
A weak OTP implementation usually checks only one thing:
Does the entered OTP match the generated OTP?
That is not enough.
A safer OTP implementation must also ask:
Is this OTP still active?
Has it expired?
Has it already been used?
Was a newer OTP generated after this one?
How many wrong attempts happened for this OTP?
How many wrong attempts happened for this user?
How many OTPs were requested from this IP?
How many accounts were targeted from this device?
Is this login/device/action pattern suspicious?
Security is rarely about one control. It is usually about layers.
Encryption matters. Strong passwords matter. But flow design also matters.
A six-digit OTP has only 1,000,000 possible combinations. That sounds large for a human, but it is small for automation if the backend allows repeated guesses without strong controls.
Better architecture direction
An OTP flow should be designed as a stateful security process.
At minimum, it needs controls around:
- OTP generation
- OTP expiry
- OTP verification
- wrong attempt limits
- resend limits
- rate limits by user, IP, device, session, and purpose
- invalidation of older OTPs
- replay protection
- suspicious-pattern detection
- logging and alerting
- step-up verification for risky actions
The important part is that these controls should not live only in the frontend.
Frontend timers are useful for user experience, but they are not security controls. Attackers can call backend APIs directly.
The backend must enforce the rules.
Example OTP state model
A practical OTP table should store more than the code.
Also, do not store the OTP in plain text. Store a hash of the OTP and compare using a safe comparison method.
public enum OtpPurpose
{
Login,
PasswordReset,
DeviceRegistration,
PaymentConfirmation,
EmailChange
}
public enum OtpStatus
{
Active,
Consumed,
Expired,
Blocked
}
public sealed class OtpChallenge
{
public Guid Id { get; init; }
public Guid UserId { get; init; }
public OtpPurpose Purpose { get; init; }
// Store a hash, not the raw OTP.
public string CodeHash { get; init; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; init; }
public DateTimeOffset ExpiresAtUtc { get; init; }
public OtpStatus Status { get; set; } = OtpStatus.Active;
public int FailedAttemptCount { get; set; }
public string? SessionId { get; init; }
public string? DeviceFingerprint { get; init; }
public string? IpAddress { get; init; }
public DateTimeOffset? ConsumedAtUtc { get; set; }
}
This lets the system answer important questions later:
Was this OTP used?
Was it expired?
Was it generated for this purpose?
Was it tied to this session or device?
How many failed attempts happened?
Was the account targeted repeatedly?
Without state, the OTP system is blind.
Example verification flow
The verification flow should reject unsafe attempts before checking the OTP value.
public async Task<OtpVerificationResult> VerifyOtpAsync(
Guid userId,
OtpPurpose purpose,
string submittedCode,
string sessionId,
string deviceFingerprint,
string ipAddress,
CancellationToken cancellationToken)
{
var rateLimit = await _otpRateLimiter.CheckAsync(
userId,
purpose,
sessionId,
deviceFingerprint,
ipAddress,
cancellationToken);
if (!rateLimit.Allowed)
{
return OtpVerificationResult.Blocked(
"Too many attempts. Please try again later.");
}
var challenge = await _otpRepository.GetLatestActiveChallengeAsync(
userId,
purpose,
cancellationToken);
if (challenge is null)
{
await _securityEvents.LogAsync(
"otp_missing_or_inactive",
userId,
ipAddress,
deviceFingerprint,
cancellationToken);
return OtpVerificationResult.Failed("Invalid or expired OTP.");
}
if (challenge.ExpiresAtUtc <= DateTimeOffset.UtcNow)
{
challenge.Status = OtpStatus.Expired;
await _otpRepository.SaveAsync(challenge, cancellationToken);
return OtpVerificationResult.Failed("Invalid or expired OTP.");
}
if (challenge.SessionId != sessionId)
{
await _securityEvents.LogAsync(
"otp_session_mismatch",
userId,
ipAddress,
deviceFingerprint,
cancellationToken);
return OtpVerificationResult.Failed("Invalid or expired OTP.");
}
if (challenge.FailedAttemptCount >= 5)
{
challenge.Status = OtpStatus.Blocked;
await _otpRepository.SaveAsync(challenge, cancellationToken);
return OtpVerificationResult.Blocked(
"Too many wrong attempts. Please request a new OTP.");
}
var isValid = _otpHasher.Verify(submittedCode, challenge.CodeHash);
if (!isValid)
{
challenge.FailedAttemptCount++;
await _otpRepository.SaveAsync(challenge, cancellationToken);
await _securityEvents.LogAsync(
"otp_wrong_attempt",
userId,
ipAddress,
deviceFingerprint,
cancellationToken);
return OtpVerificationResult.Failed("Invalid or expired OTP.");
}
challenge.Status = OtpStatus.Consumed;
challenge.ConsumedAtUtc = DateTimeOffset.UtcNow;
await _otpRepository.SaveAsync(challenge, cancellationToken);
await _securityEvents.LogAsync(
"otp_verified",
userId,
ipAddress,
deviceFingerprint,
cancellationToken);
return OtpVerificationResult.Success();
}
Notice a few design choices here:
Old OTPs are not accepted.
Expired OTPs are rejected.
Consumed OTPs cannot be reused.
Wrong attempts are counted.
Session mismatch is treated as suspicious.
Rate limiting happens before code verification.
The user receives a generic error message.
Security events are logged for review.
That is the difference between “OTP checking” and “OTP flow design.”
Invalidate old OTPs when a new one is generated
One common mistake is allowing multiple active OTPs for the same user and purpose.
That creates confusion and increases the attack surface.
A better rule is:
For the same user and same purpose, only the latest OTP should be active.
Example:
public async Task<OtpChallenge> CreateOtpAsync(
Guid userId,
OtpPurpose purpose,
string sessionId,
string deviceFingerprint,
string ipAddress,
CancellationToken cancellationToken)
{
var resendLimit = await _otpRateLimiter.CheckResendAsync(
userId,
purpose,
sessionId,
deviceFingerprint,
ipAddress,
cancellationToken);
if (!resendLimit.Allowed)
{
throw new OtpRateLimitException(
"Too many OTP requests. Please try again later.");
}
await _otpRepository.ExpireActiveChallengesAsync(
userId,
purpose,
cancellationToken);
var code = _otpGenerator.GenerateSixDigitCode();
var challenge = new OtpChallenge
{
Id = Guid.NewGuid(),
UserId = userId,
Purpose = purpose,
CodeHash = _otpHasher.Hash(code),
CreatedAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(5),
SessionId = sessionId,
DeviceFingerprint = deviceFingerprint,
IpAddress = ipAddress
};
await _otpRepository.InsertAsync(challenge, cancellationToken);
await _otpSender.SendAsync(userId, purpose, code, cancellationToken);
return challenge;
}
This prevents a situation where older OTPs remain usable after the user has requested a new code.
Rate limits should be layered
A single rate limit is not enough.
If you rate limit only by IP, an attacker can rotate IPs.
If you rate limit only by account, an attacker can target many accounts.
If you rate limit only by session, an attacker can create many sessions.
A stronger design uses layered limits:
Per user:
No more than 5 wrong OTP attempts per challenge.
Per account:
No more than 10 wrong OTP attempts across all active challenges in 15 minutes.
Per IP:
No more than 30 OTP verification attempts in 5 minutes.
Per device fingerprint:
No more than 20 OTP attempts across accounts in 10 minutes.
Per purpose:
Stricter limits for device registration, password reset, payment, and email change.
Global:
Detect spikes across the whole system.
Example policy object:
public sealed class OtpRateLimitPolicy
{
public int MaxWrongAttemptsPerOtp { get; init; } = 5;
public int MaxOtpRequestsPerUserPerHour { get; init; } = 5;
public int MaxVerifyAttemptsPerIpPerMinute { get; init; } = 10;
public int MaxVerifyAttemptsPerDevicePerMinute { get; init; } = 10;
public int MaxTargetedAccountsPerDevicePerHour { get; init; } = 3;
public TimeSpan OtpLifetime { get; init; } = TimeSpan.FromMinutes(5);
public TimeSpan TemporaryBlockDuration { get; init; } = TimeSpan.FromMinutes(15);
}
The exact numbers depend on the product, risk level, and user base. A banking flow, password manager flow, healthcare flow, or payment flow should not use the same policy as a low-risk newsletter signup.
Sensitive actions need stronger controls
Not every OTP flow has the same risk.
Logging in from a known device is one level of risk.
Registering a new device is higher risk.
Changing email is higher risk.
Resetting password is higher risk.
Confirming payment is higher risk.
Exporting sensitive data is higher risk.
For sensitive actions, the system should consider step-up controls:
Require an existing trusted session.
Require re-authentication.
Require a stronger second factor.
Delay risky changes.
Notify the user.
Block the action if risk is too high.
Require admin or support review for extreme cases.
OWASP also recommends reauthentication after risk events such as unusual login patterns, IP address changes, suspicious activity, account recovery, and adding trusted devices.
Example suspicious pattern detection
The system should not only check individual OTP attempts. It should also look for patterns.
Same account targeted from 10 IPs.
Same IP targeting 50 accounts.
Same device fingerprint trying OTPs for multiple users.
OTP attempts happening faster than human typing speed.
Multiple OTP requests without successful verification.
New device registration attempted after password reset.
Login succeeds but device, country, or behavior is unusual.
Example event model:
{
"eventType": "OtpVerificationFailed",
"userId": "user-123",
"purpose": "DeviceRegistration",
"ipAddress": "203.0.113.10",
"deviceFingerprint": "device-fp-789",
"sessionId": "session-456",
"failedAttemptCount": 4,
"timestampUtc": "2026-06-24T10:15:00Z",
"riskSignals": {
"newDevice": true,
"newIp": true,
"highVelocityAttempts": true,
"multipleAccountsFromDevice": false
}
}
Once these events exist, the system can trigger useful actions:
Temporary block.
Additional verification.
Account notification.
Device registration hold.
Security alert.
Support review.
Global rule adjustment.
Tradeoffs and constraints
OTP security has an important tradeoff:
Too loose, and attackers can automate guesses.
Too strict, and real users get locked out.
That is why the design should avoid one-dimensional controls.
Permanent lockout after a few wrong attempts can become a denial-of-service problem if attackers intentionally lock other users out. OWASP notes that account lockout policies need to balance security and usability, including threshold, observation window, and lockout duration.
A better design usually combines:
short OTP lifetime
small per-OTP attempt limit
temporary cooldown
risk-based checks
device/session binding
clear user messaging
security logging
support recovery path
The goal is not to punish users for typing mistakes. The goal is to make automated guessing expensive, slow, and visible.
Checklist for OTP flow design
Before shipping an OTP flow, I would review these questions:
Is the OTP generated using a secure random generator?
Is the OTP stored as a hash instead of plain text?
Does the OTP expire quickly?
Can the OTP be used only once?
Are old OTPs invalidated when a new one is generated?
Is there a wrong-attempt limit per OTP?
Is there rate limiting per user, IP, device, and session?
Are sensitive purposes handled with stricter rules?
Is device registration treated as a high-risk flow?
Are suspicious patterns logged and monitored?
Are error messages generic enough to avoid leaking state?
Does the backend enforce all security rules?
Is there a safe recovery path for locked users?
Closing thought
OTP is not secure just because the code is random.
OTP becomes safer when the full flow is designed properly.
The real system design question is not only:
Can we generate and verify a six-digit code?
The better question is:
What happens when someone attacks this flow at scale?
That is where good architecture matters.
Security is not only encryption, hashing, or strong passwords. It is also about designing the workflow, storing the right state, limiting abuse, and thinking like an attacker before the attacker does.