Hello Friends,
Welcome back to the Part 2 of this series. In Part 1, we walked through the security concern behind broad outbound firewall rules that allow access to any Azure Storage account, and explained why FQDNs visible in Azure Firewall logs do not tell you anything about tenant ownership. I also outlined the plan: extract storage FQDNs from your firewall logs, generate controlled traffic to each one, and use the HTTP response to extract a tenant ID that can be mapped to an owner.
In this part, we will go deeper into the actual mechanics. I will show you exactly how the tenant ID discovery technique works under the hood, walk through the PowerShell scripts that I have built, and then we will move into Azure Firewall logs with some KQL queries that will help you get from raw log data to a clean list of FQDNs ready for analysis.
How Tenant ID Discovery Works
The technique I rely on is based on a well-known and intentional behavior in Entra ID. When you send an unauthenticated HTTP request to an Azure Storage account endpoint, Azure responds with a 401 Unauthorized status. That response always includes a WWW-Authenticate header, and embedded in that header is the tenant's authorization URI, which contains the tenant ID.
Here is what the actual response header looks like:
WWW-Authenticate: Bearer authorization_uri=https://login.microsoftonline.com/<tenantId>/oauth2/authorize
resource_id=https://storage.azure.com
That single header gives us exactly what we need. The tenantId in the authorization_uri field is the Entra ID tenant that owns the storage account. This is not a side effect or an undocumented quirk: it is how Entra ID advertises which identity provider is authoritative for a given resource. Every Azure Storage account will respond this way because storage always requires Entra ID authentication.
One thing worth noting about the format: the authorization_uri value is not quoted, and the URL path continues past the tenant ID as /oauth2/authorize. This matters for the regex we will write in Step 3.
From a practical standpoint, this means:
- No credentials are required to perform the lookup
- No elevated permissions or special roles are needed
- The request itself is a simple HTTPS call, not a full authentication flow
- The technique scales well because each request is fast and lightweight
The only thing we need to do is make the request, read the WWW-Authenticate header, and extract the tenant ID from the authorization_uri value. Everything else is string parsing.
Retrieving Tenant IDs with PowerShell
Before we build any functions, let me walk through the raw mechanics step by step using a single storage account. Starting simple keeps all the moving parts visible and makes it much easier to understand what the script is actually doing later.
Step 1 – Making the request
The first thing we need is an HTTP response from the storage account with a 401 Unauthorized status, because that is the only status code that carries the WWW-Authenticate header we need.
Getting a reliable 401 turns out to be less obvious than it sounds. Storage accounts in Azure can be configured in many different ways, and a simple unauthenticated GET to the account endpoint can be handled by an Azure frontend layer or CDN before it ever reaches the storage service, resulting in a 200 with no WWW-Authenticate header at all. If you add a dummy Authorization: Bearer invalid header to try to force a rejection, some accounts return a 403 AuthenticationFailed instead of a 401, because Entra ID evaluates the Bearer token and definitively rejects it without issuing a challenge.
The combination that works reliably across all account types is two specific request headers together:
- x-ms-version: the Azure Storage REST API version header. Its presence signals to the Azure infrastructure that this is a genuine API call, routing the request through the real storage service authentication pipeline rather than any frontend layer that might serve anonymous requests transparently.
- Authorization: Bearer invalid: with x-ms-version already routing the request correctly, the storage service now sees an authentication attempt with an invalid token. It cannot skip evaluation, so it issues a 401 challenge with the
WWW-Authenticateheader.
Neither header works reliably on its own. Together, they consistently produce the 401 we need on every account type.
$fqdn = "mystorageaccount.blob.core.windows.net"
$uri = "https://$fqdn/"
$headers = @{
"x-ms-version" = "2023-11-03"
"Authorization" = "Bearer invalid"
}
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing -SkipHttpErrorCheck -TimeoutSec 10
-SkipHttpErrorCheck requires PowerShell 7.1 or later. Without it, PowerShell throws a terminating exception on a 401 response and you never reach the headers. If you are still on PowerShell 5.1, you will need to wrap the call in a try/catch and read the response from the exception object instead.
At this point $response.StatusCode will be 401. That is expected and exactly what we want.
Step 2 – reading the WWW-Authenticate header
$response.Headers["WWW-Authenticate"] returns a string array, not a plain string. If you pass that array directly to -match, PowerShell treats it as a filter operation across the array elements instead of a regex match, and $Matches comes back null. Always cast with [string] first.
$wwwAuth = [string]$response.Headers["WWW-Authenticate"]
$wwwAuth
The output will look like this:
Bearer authorization_uri=https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/oauth2/authorize resource_id=https://storage.azure.com
Notice two things about this format: the authorization_uri value has no surrounding quotes, and the URL continues past the tenant ID GUID into /oauth2/authorize. Both of these matter for the regex in Step 3. The only field we care about is authorization_uri, because the GUID in that URL is the tenant ID of the storage account owner.
Step 3 – Extracting the tenant ID
We use a simple regex against the raw header string. The -match operator populates the automatic $Matches variable if the pattern is found, and $Matches[1] gives us the first capture group: the tenant ID.
if ($wwwAuth -match 'authorization_uri=https://login\.microsoftonline\.com/([^/\s]+)') {
$tenantId = $Matches[1]
Write-Host "Tenant ID: $tenantId"
}
The pattern ([^/\s]+) matches one or more characters that are not / or whitespace. Because there are no quotes around the URI, we stop at the first / that follows the GUID, which is the /oauth2/authorize path segment, giving us a clean tenant ID with nothing trailing.
At this point we have the tenant ID. That single value is already enough to answer the most important question: does this storage account belong to my tenant or not?
Step 4 – Confirming the tenant via OpenID Connect
If you want to go one step further and confirm the tenant ID is valid, there is a public endpoint you can call without any credentials. Every Entra ID tenant exposes an OpenID Connect discovery document at a predictable URL. We just need to ask for it:
$oidcUri = "https://login.microsoftonline.com/$tenantId/v2.0/.well-known/openid-configuration"
$oidcData = Invoke-RestMethod -Uri $oidcUri -UseBasicParsing -TimeoutSec 10
$oidcData.issuer
The output will be something like:
https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/v2.0
If the tenant ID is invalid or does not exist, this call will return a 400 error instead. A successful response means the tenant is a real, active Entra ID tenant. The issuer field echoes back the same tenant ID, which is useful as a confirmation that what we extracted from the WWW-Authenticate header is correct.
One honest limitation here: the OpenID Connect discovery endpoint does not return a human-friendly display name like "Contoso Ltd." Resolving a tenant ID to an organization name requires an authenticated call to the Microsoft Graph API, which means you can only do it for tenants where you have an existing relationship or the appropriate permissions. For unknown external tenants, the tenant ID itself is already enough to make a decision: you can check it against your own tenant ID, a list of approved partner IDs, or flag it as outside your expected boundary.
Putting it all together in one block
Now that each step is clear, here is the same logic written as a single inline script for a single FQDN, with no functions involved:
$fqdn = "mystorageaccount.blob.core.windows.net"
$tenantId = $null
$issuer = $null
$headers = @{
"x-ms-version" = "2023-11-03"
"Authorization" = "Bearer invalid"
}
$response = Invoke-WebRequest -Uri "https://$fqdn/" -Headers $headers `
-UseBasicParsing -SkipHttpErrorCheck -TimeoutSec 10
$wwwAuth = [string]$response.Headers["WWW-Authenticate"]
if ($wwwAuth -match 'authorization_uri=https://login\.microsoftonline\.com/([^/\s]+)') {
$tenantId = $Matches[1]
}
if ($tenantId) {
$oidcData = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/v2.0/.well-known/openid-configuration" `
-UseBasicParsing -TimeoutSec 10
$issuer = $oidcData.issuer
}
[PSCustomObject]@{
FQDN = $fqdn
TenantId = $tenantId
Issuer = $issuer
}
Running that gives you:
FQDN TenantId Issuer
---- -------- ------
mystorageaccount.blob.core.windows.net xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx https://login.microsoftonline.com/xxxxxxxx.../v2.0
Scaling up: wrapping it in a function
The inline version works perfectly for a single FQDN, but when you are dealing with hundreds of entries extracted from Azure Firewall logs you need something you can loop over reliably. The function below is a direct translation of the four steps above, with a foreach loop, proper error handling, and a status field so you can tell at a glance what happened with each entry.
function Get-StorageAccountTenantId {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string[]] $storageFqdns
)
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
ForEach ($fqdn in $storageFqdns) {
Write-Verbose "Processing: $fqdn"
$tenantId = $null
$issuer = $null
$status = 'Unknown'
try {
$probeHeaders = @{
"x-ms-version" = "2023-11-03"
"Authorization" = "Bearer invalid"
}
$response = Invoke-WebRequest -Uri "https://$fqdn/" -Headers $probeHeaders `
-UseBasicParsing -SkipHttpErrorCheck -TimeoutSec 10 -ErrorAction Stop
$wwwAuth = [string]$response.Headers["WWW-Authenticate"]
if ($wwwAuth -match 'authorization_uri=https://login\.microsoftonline\.com/([^/\s]+)') {
$tenantId = $Matches[1]
$status = 'Resolved'
}
else {
$status = 'No-WWW-Authenticate-Header'
}
if ($tenantId) {
$oidcData = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/v2.0/.well-known/openid-configuration" `
-UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
$issuer = $oidcData.issuer
}
}
catch {
$status = "Error: $($_.Exception.Message)"
}
$results.Add([PSCustomObject]@{
FQDN = $fqdn
TenantId = $tenantId
Issuer = $issuer
Status = $status
})
}
return $results
}
Every line in this function maps directly back to one of the four steps above. There is nothing new here: it is the same HTTP request, the same header read, the same regex, and the same OIDC call, just wrapped in a loop with error handling around it.
Running the Script
Once the function is in place, running it against a list of FQDNs is straightforward. You can pass a static list directly, or feed in values extracted from Azure Firewall logs.
$fqdns = @(
"mystorageaccount.blob.core.windows.net",
"backupstore01.blob.core.windows.net",
"somecompanylogs.dfs.core.windows.net"
)
$results = Get-StorageAccountTenantId -storageFqdns $fqdns -Verbose
$results | Format-Table -AutoSize
The output will look something like this:
FQDN TenantId Issuer Status
---- -------- ------ ------
mystorageaccount.blob.core.windows.net xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx https://login.microsoftonline.com/xxxxxxxx.../v2.0 Resolved
backupstore01.blob.core.windows.net yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy https://login.microsoftonline.com/yyyyyyyy.../v2.0 Resolved
somecompanylogs.dfs.core.windows.net zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz https://login.microsoftonline.com/zzzzzzzz.../v2.0 Resolved
From here, you can filter for tenant IDs that do not match your own, export to CSV, or pipe the results into a reporting workflow.
$results | Export-Csv -Path ".\storage-tenant-report.csv" -NoTypeInformation
$myTenantId = (Get-AzContext).Tenant.Id
$results | Where-Object { $_.TenantId -ne $myTenantId -and $_.TenantId -ne $null }
What Comes Next
At this point we have a working script: give it a list of storage FQDNs and it gives you back a tenant ID for each one. That is the core of the discovery capability.
What we have not covered yet is where that list of FQDNs actually comes from in a real environment. In Part 3, we will get into Azure Firewall logs properly. I will walk through the two log formats Azure Firewall supports, show the KQL queries I use to extract storage FQDNs from both of them, and then connect the output directly to the script we built here so the whole pipeline runs end to end from log data to tenant report.
Stay tuned for Part 3!