Did you know that it is possible to perform every step in Entra’s OAuth 2.0 Device Code flow — including the user authentication steps — without a browser?
Why that matters:
Yes, but:
Automating device code flow requires five requests:
Request One: POST to https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0
In this request, the application initiates the flow and receives a “user code” back from the devicecode API. This “user code” is what a human being would enter into the browser later:
# Device Code OAuth flow begins:
$body = @{
"client_id" = "1b730954–1685–4b74–9bfd-dac224a7b894"
"resource" = "https://graph.microsoft.com"
}
$UserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36"
$Headers=@{}
$Headers["User-Agent"] = $UserAgent
$authResponse = Invoke-RestMethod `
-UseBasicParsing `
-Method Post `
-Uri "https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0" `
-Headers $Headers `
-Body $body
$OTC = $authResponse.user_code
Request Two: GET to https://login.microsoftonline.com/common/oauth2/deviceauth
The next request is performed “as the user” and is a simple request to the initial deviceauth page:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"
$SecondRequest = $null
$SecondRequest = Invoke-WebRequest `
-UseBasicParsing `
-Uri "https://login.microsoftonline.com/common/oauth2/deviceauth" `
-WebSession $session `
The response to this request includes our first collection of cookies and unique identifiers that we must keep track of during the flow:
x-ms-request-id — The value of this response header is required in later requests as the value for “hpgrequestid”
fpc — This cookie is required in later requests
esctx — This cookie is required in later requests
hpgid — A four-digit code that is required in later requests and must be parsed out of the response body HTML
canary — A token required in later requests, must be parsed from the response body HTML
We can assign all of these cookies, tokens, and other identifiers to their relevant variables with some basic parsing of the response headers and body in PowerShell:
$hpgrequestid = $SecondRequest.Headers.'x-ms-request-id'
$CookieFPC = (($SecondRequest.Headers.'Set-Cookie' | select -first 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1
$CookieESCTX = (($SecondRequest.Headers.'Set-Cookie' | select -first 2 | Select -Last 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1
$html = $SecondRequest.Content
$pattern = ',"hpgid":(.*?),"pgid"'
$match = $html | Select-String -Pattern $pattern
if ($match) {
$HPGID = $match.Matches.Groups[1].Value
Write-Output "HPGID: $HPGID"
} else {
Write-Output "HPGID not found in the HTML."
}
$pattern = '","canary":"(.*?)","sCanaryTokenName"'
$match = $html | Select-String -Pattern $pattern
if ($match) {
$desiredString = $match.Matches.Groups[1].Value
$Canary = [System.Web.HttpUtility]::UrlEncode($desiredString)
Write-Output "Canary: $Canary"
} else {
Write-Output "Canary not found in the HTML."
}
This code block shows what each value looks like:
$hpgrequestid: e2a89eff-5dd0–4262-b27b-4ee2468a5900
$CookieFPC AuxOg1aj0H5EpEVcclZOs4Q
$CookieESCTX PAQABAAEAAAD - DLA3VO7QrddgJg7WevrzJCyhs37r8aEEog6PXOivCF953PRt68FvlHkjFnSplN2mNHQwqEBcTTmf5EPXTIRQCQXFrA27_cEk2l3YG0F1JreF8T9WwL5PJldV5XZjdy2RF-A-EtDsFx_MHGWSV-FSw1Prci4lcfiDl7vsxQMqXKGgaUSdNPbA9iJPFfIh8cgAA
$HPGID 1119
$Canary t%2frFkhW25KShBl3S2O7pyXaB6GA3P3orfpFUxom3RH4%3d7%3a1%3aCANARY%3acDaoWAl7lE%2f5UyKpxyvQpCptwytSp3fwGEJMnTyNAXQ%3d
Request Three: POST to https://login.microsoftonline.com/common/oauth2/deviceauth
In this request, we include the ESCTX cookie, One-Time Code (OTC), Canary, and hpgrequestid values when POSTing to the deviceauth endpoint:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"
$session.Cookies.Add((New-Object System.Net.Cookie("esctx", $CookieESCTX, "/", ".login.microsoftonline.com")))
$ThirdRequest = $null
$ThirdRequest = Invoke-WebRequest `
-UseBasicParsing `
-Uri "https://login.microsoftonline.com/common/oauth2/deviceauth" `
-Method "POST" `
-WebSession $session `
-ContentType "application/x-www-form-urlencoded" `
-Body "otc=$($OTC)&canary=$($Canary)&flowToken=&hpgrequestid=$($hpgrequestid)"
The response will include NEW values for hpgrequestid and ESCTX, so we need to update those values before we use them in subsequent requests:
$hpgrequestid = $ThirdRequest.Headers.'x-ms-request-id'
$CookieESCTX = (($ThirdRequest.Headers.'Set-Cookie' | select -First 4 | Select -Last 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1
We need to extract the updated value for “canary” from the request body to use in subsequent requests:
# Find the value of "canary"
$pattern = '","canary":"(.*?)","sCanaryTokenName"'
$match = $html | Select-String -Pattern $pattern
if ($match) {
$desiredString = $match.Matches.Groups[1].Value
$Canary = [System.Web.HttpUtility]::UrlEncode($desiredString)
Write-Output "Canary: $Canary"
} else {
Write-Output "Canary not found in the HTML."
}
We also need to extract the value of “sCtx” from the body “sFT”, as they are used in subsequent requests for the values of “OriginalRequest” and “sFT”:
# Find the value of "sCtx"
$html = $ThirdRequest.Content
$pattern = 'login\.microsoftonline\.com%2fcommon%2freprocess%3fctx%3d(.*?)\\u0026mkt=en-US\\u0026hosted=1'
$match = $html | Select-String -Pattern $pattern
if ($match) {
$desiredString = $match.Matches.Groups[1].Value
$OriginalRequest = [System.Web.HttpUtility]::UrlDecode($desiredString)
Write-Output "Original request: $OriginalRequest"
} else {
Write-Output "Original request not found in the HTML."
}
# Find the value of "sFT"
$pattern = '","sFT":"(.*?)","sFTName"'
$match = $html | Select-String -Pattern $pattern
if ($match) {
$sFT = $match.Matches.Groups[1].Value
Write-Output "sFT: $sFT"
} else {
Write-Output "sFT not found in the HTML."
}
Request Four: GET to https://login.microsoftonline.com/common/login
This is the request where we finally send the username and password. This request requires the ESCTX, Canary, OriginalRequest, hpgrequestid, and sFT cookies/tokens as well:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Req5"
$session.Cookies.Add((New-Object System.Net.Cookie("esctx", $CookieESCTX, "/", ".login.microsoftonline.com")))
$FourthRequest = $null
$FourthRequest = Invoke-WebRequest `
-UseBasicParsing `
-Uri "https://login.microsoftonline.com/common/login" `
-Method "POST" `
-WebSession $session `
-ContentType "application/x-www-form-urlencoded" `
-Body "i13=0&login=jasonfrank%40specterdev.onmicrosoft.com&loginfmt=jasonfrank%40specterdev.onmicrosoft.com&type=11&LoginOptions=3&lrt=&lrtPartition=&hisRegion=&hisScaleUnit=&passwd=<cleartext password goes here>&ps=2&psRNGCDefaultType=&psRNGCEntropy=&psRNGCSLK=&canary=$($Canary)&ctx=$($OriginalRequest)&hpgrequestid=$($hpgrequestid)&flowToken=$($sFT)&PPSX=&NewUser=1&FoundMSAs=&fspost=0&i21=0&CookieDisclosure=0&IsFidoSupported=1&isSignupPost=0&i19=125513"
Next, we must update the values of ESCTX and sFT from this request’s response:
$CookieESCTX = (($FourthRequest.Headers.'Set-Cookie' | select -First 2 | Select -Last 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1
# Find the value of "sFT"
$html = $FourthRequest.Content
$pattern = '","sFT":"(.*?)","sFTName"'
$match = $html | Select-String -Pattern $pattern
if ($match) {
$sFT = $match.Matches.Groups[1].Value
Write-Output "sFT: $sFT"
} else {
Write-Output "sFT not found in the HTML."
}
Request Five: POST to https://login.microsoftonline.com/appverify
In this request, we supply the ESCTX, OriginalRequest, hprequestid, sFT, and Canary cookies/tokens:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Req6"
$session.Cookies.Add((New-Object System.Net.Cookie("esctx", $CookieESCTX, "/", ".login.microsoftonline.com")))
$FifthRequest = Invoke-WebRequest `
-UseBasicParsing `
-Uri "https://login.microsoftonline.com/appverify" `
-Method "POST" `
-WebSession $session `
-ContentType "application/x-www-form-urlencoded" `
-Body "ContinueAuth=true&ctx=$($OriginalRequest)&hpgrequestid=$($hpgrequestid)&flowToken=$($sFT)&iscsrfspeedbump=false&canary=$($Canary)&i19=43609"
This is equivalent to clicking “Yes” when the browser is asking if you are trying to log into the application. We do not need anything from this request’s response.
Request Six: POST to https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0
Here, we are going back to the context of submitting requests as the application, not the user. In this request, we supply the “device code” we retrieved from the very first request:
$body=@{
"client_id" = "1b730954–1685–4b74–9bfd-dac224a7b894"
"grant_type" = "urn:ietf:params:oauth:grant-type:device_code"
"code" = $authResponse.device_code
}
$SixthRequest = Invoke-RestMethod `
-UseBasicParsing `
-Method Post `
-Uri "https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0" `
-Headers $Headers `
-Body $body
If Requests Two through Five resulted in valid authentication, this request response includes a refresh token, access token, and ID token for the user:
$SixthRequest
token_type : Bearer
scope : Agreement.Read.All Agreement.ReadWrite.All AgreementAcceptance.Read AgreementAcceptance.Read.All AuditLog.Read.All Directory.AccessAsUser.All
Directory.ReadWrite.All Group.ReadWrite.All IdentityProvider.ReadWrite.All Policy.ReadWrite.TrustFramework PrivilegedAccess.ReadWrite.AzureAD
PrivilegedAccess.ReadWrite.AzureADGroup PrivilegedAccess.ReadWrite.AzureResources TrustFrameworkKeySet.ReadWrite.All User.Invite.All
expires_in : 4593
ext_expires_in : 4593
expires_on : 1689961479
not_before : 1689956585
resource : https://graph.microsoft.com
access_token : eyJ0eX…ba0hpg
refresh_token : 0.AVEA…h2P7mA
id_token : eyJ0eX…IiOiIx
In this blog post, I’m sharing my current knowledge and testing regarding automating Entra’s OAuth 2.0 Device Code flow. Please feel free to build on, correct, or adapt this information to your own needs. I’m sharing this now because, frankly, it took a long time to figure out and I want to save others the pain I went through.
I’m using this to research other Entra systems such as Conditional Access. Automating this flow means I can very quickly perform tests against Conditional Access to validate, for example, how the backend systems determine a user’s browser and OS types from the signals in these requests.
Browserless Entra Device Code Flow was originally published in Posts By SpecterOps Team Members on Medium, where people are continuing the conversation by highlighting and responding to this story.
*** This is a Security Bloggers Network syndicated blog from Posts By SpecterOps Team Members - Medium authored by Andy Robbins. Read the original post at: https://posts.specterops.io/browserless-entra-device-code-flow-0802f3bbb91a?source=rss----f05f8696e3cc---4