Your Sign-Up Flow Has a Backdoor
You asked your AI to add email verification. It did — along with hardcoded bypass codes, OTPs in API responses, and zero rate limiting. Here are the seven patterns to check right now.
You asked for email verification. You got a backdoor.
You're building a sign-up flow. You want email verification because you've seen enough apps get hammered by bots and fake accounts. So you tell your AI: "Add OTP-based email verification to the registration flow."
Thirty seconds later, you have a working verification system. Users sign up, get a code, enter it, and they're in. It works. It looks professional. You ship it.
Here's the problem: the AI also left the OTP in the API response body. Or it hardcoded 1234 as a bypass. Or it generated 4-digit codes with no rate limiting — which means an attacker can try all 10,000 combinations in under a minute.
These aren't edge cases. They're the default patterns AI generates when you ask for email verification. Let's look at each one.
Pattern 1: OTP returned in the API response
This is the most common one. The AI builds a verification endpoint that sends an OTP to the user's email — but also returns it in the JSON response.
// VULNERABLE — OTP leaked in response body
app.post("/api/auth/send-otp", async (req, res) => {
const { email } = req.body;
const otp = generateOTP();
await saveOTP(email, otp);
await sendEmail(email, `Your code is: ${otp}`);
res.json({
success: true,
message: "OTP sent successfully",
otp: otp, // For debugging... left in production
debug: { code: otp } // Or hidden in a debug object
});
});
The AI includes the OTP in the response because it's helpful during development. You can see the code without checking your email. That's convenient. It's also a complete bypass of your entire verification system.
An attacker doesn't need access to the user's inbox. They call the endpoint, read the response, and submit the code. Your email verification is now decorative.
The fix: Never return the OTP in any form. Not in the response body, not in headers, not in a debug field. If you need to see codes during development, log them server-side or use a test email service like Mailhog.
// FIXED — no OTP in response
app.post("/api/auth/send-otp", async (req, res) => {
const { email } = req.body;
const otp = generateOTP();
await saveOTP(email, otp);
await sendEmail(email, `Your code is: ${otp}`);
res.json({
success: true,
message: "Verification code sent"
// Nothing else. No code. No debug field.
});
});
Pattern 2: Predictable OTP generation
AI loves clean code. And what's cleaner than deriving the OTP from something deterministic?
// VULNERABLE — timestamp-based OTP
function generateOTP() {
const timestamp = Date.now();
return String(timestamp % 10000).padStart(4, "0");
}
// ALSO VULNERABLE — sequential OTPs
let otpCounter = 1000;
function generateOTP() {
otpCounter++;
return String(otpCounter);
}
If the OTP is derived from the current timestamp, an attacker who knows roughly when the request was made can narrow the possibilities to a handful of codes. If it's sequential, they just need one valid code to predict every future one.
The fix: Use a cryptographically secure random number generator.
// FIXED — cryptographically random OTP
import crypto from "crypto";
function generateOTP(length = 6) {
const max = Math.pow(10, length);
const randomBytes = crypto.randomInt(0, max);
return String(randomBytes).padStart(length, "0");
}
Use crypto.randomInt() in Node.js, secrets.randbelow() in Python, or SecureRandom in Ruby. Never Math.random(). Never timestamp math.
Pattern 3: Hardcoded backdoor codes
This one is subtle. The AI generates a verification endpoint with a built-in test code:
// VULNERABLE — hardcoded bypass code
app.post("/api/auth/verify-otp", async (req, res) => {
const { email, otp } = req.body;
// Development bypass
if (otp === "0000" || otp === "1234" || otp === "123456") {
await verifyUser(email);
return res.json({ success: true, token: generateToken(email) });
}
const stored = await getStoredOTP(email);
if (otp === stored) {
await verifyUser(email);
return res.json({ success: true, token: generateToken(email) });
}
res.status(400).json({ success: false });
});
The AI adds this because you're developing locally and don't want to check email every time you test. Reasonable. But 0000 and 1234 are the first codes any attacker tries. They work on every account, forever, regardless of what actual OTP was sent.
The fix: Remove all hardcoded codes before shipping. If you need a development bypass, gate it behind an environment variable that doesn't exist in production:
// FIXED — bypass only in development
if (process.env.NODE_ENV === "development" && otp === "000000") {
await verifyUser(email);
return res.json({ success: true, token: generateToken(email) });
}
Better yet, don't have a bypass at all. Use a local email server like Mailhog or Mailpit and test with real codes.
Pattern 4: No rate limiting on verification attempts
A 4-digit OTP has 10,000 possible values. A 6-digit OTP has 1,000,000. Without rate limiting, an attacker can try them all.
// VULNERABLE — no rate limiting, no attempt tracking
app.post("/api/auth/verify-otp", async (req, res) => {
const { email, otp } = req.body;
const stored = await getStoredOTP(email);
if (otp === stored) {
await verifyUser(email);
return res.json({ success: true });
}
res.status(400).json({ success: false, message: "Invalid code" });
});
This endpoint will happily accept 10,000 requests in rapid succession. With a 4-digit code, an attacker using a simple script finishes in under a minute. Even a 6-digit code falls in a few hours at modest request rates.
AI almost never generates rate limiting for verification endpoints. It solves the stated problem — "verify the code" — and doesn't think about what happens when someone guesses.
The fix: Limit attempts and lock out after failures.
// FIXED — rate limiting + attempt tracking
app.post("/api/auth/verify-otp", async (req, res) => {
const { email, otp } = req.body;
const attempts = await getAttemptCount(email);
if (attempts >= 5) {
await invalidateOTP(email);
return res.status(429).json({
success: false,
message: "Too many attempts. Request a new code."
});
}
await incrementAttemptCount(email);
const stored = await getStoredOTP(email);
if (otp === stored) {
await verifyUser(email);
await clearAttempts(email);
return res.json({ success: true });
}
res.status(400).json({ success: false });
});
Five attempts, then the code is burned. The user needs to request a new one. This makes brute force impossible regardless of code length.
Pattern 5: OTPs that never expire
AI generates OTPs and stores them. It doesn't set an expiration.
// VULNERABLE — OTP stored without expiry
async function saveOTP(email, otp) {
await db.query(
"INSERT INTO otp_codes (email, code) VALUES ($1, $2)",
[email, otp]
);
}
// Later, verification just checks if the code matches
async function verifyOTP(email, otp) {
const result = await db.query(
"SELECT * FROM otp_codes WHERE email = $1 AND code = $2",
[email, otp]
);
return result.rows.length > 0;
}
That code from three weeks ago? Still valid. An attacker who intercepts or leaks an old code can use it indefinitely. Even codes the user never entered sit in the database, waiting.
The fix: Add a timestamp and enforce expiration.
// FIXED — OTP expires after 10 minutes
async function saveOTP(email, otp) {
await db.query(
`INSERT INTO otp_codes (email, code, created_at)
VALUES ($1, $2, NOW())`,
[email, otp]
);
}
async function verifyOTP(email, otp) {
const result = await db.query(
`SELECT * FROM otp_codes
WHERE email = $1
AND code = $2
AND created_at > NOW() - INTERVAL '10 minutes'`,
[email, otp]
);
return result.rows.length > 0;
}
Ten minutes is generous. Five is better. The shorter the window, the less time an attacker has to work with.
Pattern 6: OTP not invalidated after use
The user enters their code. It works. They're verified. But the code still sits in the database, valid and reusable.
// VULNERABLE — OTP still valid after successful verification
app.post("/api/auth/verify-otp", async (req, res) => {
const { email, otp } = req.body;
const valid = await verifyOTP(email, otp);
if (valid) {
await markEmailVerified(email);
return res.json({ success: true });
// OTP is never deleted or marked as used
}
res.status(400).json({ success: false });
});
This means the same code can be submitted again. And again. If an attacker intercepts the code — from a network log, a browser extension, or shoulder surfing — they can replay it at any time. Combined with no expiration (Pattern 5), this creates a permanent backdoor tied to every code ever generated.
The fix: Delete or invalidate the code immediately after successful verification.
// FIXED — OTP deleted after use
if (valid) {
await markEmailVerified(email);
await deleteOTP(email); // One-time use
return res.json({ success: true });
}
One code, one use. That's it.
Pattern 7: Error messages that leak code details
Sometimes the AI generates error responses that tell the attacker more than they should know.
// VULNERABLE — error response leaks information
app.post("/api/auth/verify-otp", async (req, res) => {
const { email, otp } = req.body;
const stored = await getStoredOTP(email);
if (!stored) {
return res.status(400).json({
success: false,
message: "No OTP found for this email. Request a new 6-digit code."
});
}
if (otp.length !== stored.length) {
return res.status(400).json({
success: false,
message: `Expected ${stored.length}-digit code`
});
}
if (otp !== stored) {
return res.status(400).json({
success: false,
message: "Incorrect code. You have 4 attempts remaining."
});
}
});
This response tells the attacker three things: that a code exists for this email, exactly how long it is, and how many attempts they have left. Each piece of information narrows the attack. Knowing the code is 6 digits instead of 8 reduces the search space by 100x.
The fix: Generic error messages. Same response regardless of what went wrong.
// FIXED — generic error, no information leakage
res.status(400).json({
success: false,
message: "Verification failed. Please try again."
});
The user experience is slightly less helpful. The security is significantly better.
Why AI generates all of these
These seven patterns share a common root cause: AI optimizes for the happy path. When you ask for email verification, it builds a system that works when used as intended. It doesn't model what happens when someone uses it adversarially.
The OTP in the response body? Helpful for the developer. The hardcoded bypass? Convenient for testing. The missing rate limit? Not needed when you're the only user. The perpetual codes? Nobody asked for expiration.
Each pattern is a reasonable development shortcut that becomes a security hole in production. And AI doesn't distinguish between development and production. It writes code for the prompt you gave it, not the threat model you didn't mention.
What you should do right now
If your app has email or phone verification, run through this checklist:
-
Check your API responses. Call your send-OTP endpoint and read the full response body. If the code appears anywhere — in any field, including nested objects — strip it out.
-
Search for hardcoded codes. Grep your codebase for
"0000","1234","123456", and any other test values. Remove them or gate them behind a development-only environment check. -
Test your rate limiting. Send 20 wrong codes to your verification endpoint. If it lets you keep going, add attempt tracking and lockout.
-
Check code expiration. Look at how your OTPs are stored. Is there a
created_attimestamp? Is it checked during verification? If not, add a 5-10 minute expiry window. -
Verify one-time use. Enter a valid code. Then try entering it again. If it works twice, add cleanup logic that deletes the code after successful verification.
-
Review your error messages. Check that failed verification returns the same generic message regardless of what went wrong.
-
Flowpatrol checks for all of these patterns automatically — OTP leaks, missing rate limits, hardcoded bypasses, and replay vulnerabilities. Paste your URL and see what comes back.
You added email verification because you care about security. Make sure it actually works.