Friday, July 31, 2026

Generating a Microsoft Graph Bearer Token in Power Automate Desktop ( App ID + Secret )

Generating a Microsoft Graph Bearer Token in Power Automate Desktop (App ID + Secret)

Power Automate Desktop has no native Microsoft Graph connector. If you want a desktop flow to create users, read directories, send mail, or touch SharePoint through Graph, you have to do what every daemon application does: acquire an app-only access token with the OAuth 2.0 client credentials grant, then attach it as a Bearer header on every call.

This post walks through a reusable GetGraphToken subflow that does exactly that — including the three configuration details that silently break it, and a validation bug that is very easy to write backwards.


1. What we're building

Component Purpose
Entra ID app registration Identity of the automation (client ID + secret)
Main flow Sets configuration variables, calls the token subflow
GetGraphToken subflow Reads the secret, POSTs to the token endpoint, parses and validates the response
AuthHeader variable Bearer <token> string reused by every subsequent Graph call

Keeping token acquisition in its own subflow means every Graph action in the flow calls one place, and token caching becomes a single change instead of ten.


2. The protocol, per Microsoft

Client credentials is a two-legged flow: permissions are granted directly to the application by an administrator, and there is no user in the picture. Microsoft's guidance is worth quoting on the shape of the request.

Token request — POST to the tenant-specific v2.0 token endpoint:

POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

client_id={client-id}
&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default
&client_secret={secret-value}
&grant_type=client_credentials
Parameter Required Notes
tenant Yes GUID or domain name (e.g. contoso.onmicrosoft.com)
client_id Yes The Application (client) ID from the app registration
scope Yes Resource identifier suffixed with /.default. For Graph: https://graph.microsoft.com/.default. All scopes in one request must belong to a single resource
client_secret Yes The secret Value, not the Secret ID. Microsoft's docs state it must be URL-encoded before being sent
grant_type Yes Literal client_credentials

Successful response:

{
  "token_type": "Bearer",
  "expires_in": 3599,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
}

expires_in is in seconds. There is no refresh token in this flow — when the token expires you simply call /token again.

Error response is HTTP 400 with a JSON body containing error, error_description, error_codes, trace_id, and correlation_id. The error_description carries the AADSTS code you'll actually debug from.

Note on the /.default scope: it tells the identity platform to issue a token for whichever application permissions have already been consented for that resource. It does not grant anything. If admin consent hasn't been given, you still get a token — it just comes back without the roles you need, and Graph answers 403.


3. App registration checklist

Step Where Detail
Register app Entra admin center → App registrations → New registration Single tenant is fine for internal automation
Copy identifiers Overview blade Application (client) ID, Directory (tenant) ID
Add permissions API permissions → Microsoft Graph → Application permissions Delegated permissions do not work here — there is no user to act on behalf of
Grant consent API permissions → Grant admin consent Required. Without it the token is issued but carries no roles claim
Create secret Certificates & secrets → New client secret Copy the Value immediately; it is never shown again

Client secret lifetime: Microsoft caps portal-created secrets at 24 months and explicitly recommends setting expiry to less than 12 months. Secrets expire without any built-in notification, so put the expiry date in a calendar or build an expiry report — a silently expired secret is one of the most common causes of "the robot stopped working overnight."

For anything production-grade, prefer a certificate credential or workload identity federation over a shared secret. Both are supported by the same client credentials grant; only the client authentication portion of the request changes.


4. Main flow: configuration variables

Keep configuration out of the subflow so the subflow stays reusable.

SET TenantId TO $'''<your-tenant-id-or-domain>'''
SET ClientId TO $'''<your-application-client-id>'''
SET TokenUrl TO $'''https://login.microsoftonline.com/%TenantId%/oauth2/v2.0/token'''
SET GraphBase TO $'''https://graph.microsoft.com/v1.0'''
SET GraphScope TO $'''https://graph.microsoft.com/.default'''
SET LabRoot TO $'''C:\GraphLab'''
CALL GetGraphToken

Two things to note:

  • The tenant ID and client ID are not secrets, but they are environment-specific. If the flow moves between dev and prod, promote them to desktop flow input variables rather than hardcoding them.
  • %TenantId% inside the URL works because PAD evaluates variables inside $'''...''' text literals. If your URL ever contains a literal %, escape it as %%.

5. Where the secret lives

Never hardcode the secret in a Set variable action. The flow definition is stored in Dataverse and is visible to anyone who can open the flow. Three workable options:

Approach How it works Trade-off
Windows Credential Manager Run PowerShell script reads the secret from the local vault Simple; per-machine and per-user, so it doesn't travel with the flow. Requires a third-party PowerShell module
Azure Key Vault via a parent cloud flow Cloud flow uses the Key Vault Get secret action, passes it into the desktop flow as an input variable Microsoft's documented pattern; centralised rotation. Requires a cloud flow and Key Vault access
Certificate credential App authenticates with a signed JWT assertion instead of a secret Highest assurance, no secret to leak. More work to implement in PAD

Option A — Credential Manager

The Get-StoredCredential cmdlet comes from the community CredentialManager module on the PowerShell Gallery (MIT-licensed, originally by Dave Garnar). It is not shipped with Windows, so it must be installed on every machine that runs the flow:

Install-Module CredentialManager -Scope AllUsers
New-StoredCredential -Target 'GraphAutomation' -UserName 'app' `
    -Password '<secret-value>' -Type Generic -Persist LocalMachine

Microsoft's own Microsoft.PowerShell.SecretManagement + SecretStore modules are an alternative, but note the PowerShell team has declared them feature-complete and no longer actively developed (security and critical bug fixes only), so weigh that before standardising on them.

Option B — Key Vault via cloud flow

Microsoft's RPA guidance documents this end to end: the cloud flow calls Azure Key Vault → Get secret with Secure Outputs enabled, then calls the desktop flow with Secure Inputs enabled. On the PAD side, define an input variable and toggle Mark as sensitive so the value is masked in both cloud and desktop run logs.

This is the option to reach for if the flow runs unattended on a machine group.


6. The GetGraphToken subflow

6.1 Read and URL-encode the secret

PAD's Run PowerShell script action starts an instance of powershell.exe and passes the script as an argument — that's Windows PowerShell, not PowerShell 7, unless your PATH resolves powershell.exe elsewhere.

Scripting.RunPowershellScript.RunScript Script: $'''$ErrorActionPreference = \'Stop\'
Import-Module CredentialManager -ErrorAction Stop
$cred = Get-StoredCredential -Target \"GraphAutomation\"
if ($cred) {
    $plain = $cred.GetNetworkCredential().Password
    [uri]::EscapeDataString($plain)
} else {
    throw \"Credential \'GraphAutomation\' not found in Credential Manager.\"
}''' ScriptOutput=> PSSecretOutput ScriptError=> PSSecretError
Text.Trim Text: PSSecretOutput TrimOption: Text.TrimOption.Both TrimmedText=> PSSecretOutput

IF IsNotBlank(PSSecretError) THEN
    Display.ShowMessageDialog.ShowMessage Message: $'''Secret retrieval failed: %PSSecretError%''' Icon: Display.Icon.Error Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> ButtonPressed
    EXIT FUNCTION
END

Two deliberate additions over the naive version:

Capture ScriptError. Run PowerShell script produces two outputs: ScriptOutput (standard output) and ScriptError (Write-Error, runtime errors, syntax errors). If you only bind ScriptOutput and the credential is missing, PSSecretOutput comes back empty and the flow marches on to build a malformed request body — you then debug the token endpoint instead of the vault.

Encode the secret in PowerShell. [uri]::EscapeDataString() percent-encodes the value. This matters because of the next section.

Always trim. PowerShell output arrives with a trailing newline. An untrimmed secret produces AADSTS7000215 and a long afternoon.

6.2 The token request — and the setting that breaks everything

Web.InvokeWebService.InvokeWebServicePost Url: TokenUrl Accept: $'''application/json''' ContentType: $'''application/x-www-form-urlencoded''' RequestBody: $'''grant_type=client_credentials&client_id=%ClientId%&client_secret=%PSSecretOutput%&scope=%GraphScope%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: False EncodeRequestBody: False UserAgent: $'''Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.21) Gecko/20100312 Firefox/3.6''' Encoding: Web.Encoding.utf_8 AcceptUntrustedCertificates: False TrimRequestBody: True Response=> TokenResponse StatusCode=> TokenStatusCode
    ON ERROR REPEAT 5 TIMES WAIT 3
    END
Setting Value Why
Content type application/x-www-form-urlencoded The token endpoint rejects JSON
Encode request body (Advanced) Off The single most common failure. Leave it on and PAD encodes the entire body — including the & and = separators — so the endpoint sees one opaque blob and replies AADSTS7000218: The request body must contain the following parameter: 'grant_type'
Fail on error status Off Lets you read the 400 body and its AADSTS code instead of getting a bare PAD exception
Follow redirection On/Off Either works for this endpoint; several community write-ups turn it off as a precaution
Connection timeout 30 Reasonable default

Because Encode request body must be off, PAD will not percent-encode the secret for you — which is why we did it in PowerShell in step 6.1. Entra secret values regularly contain characters like ~, ., -, and _ that are safe in a form body, but they can also contain characters that are not. Encoding it once, at the source, removes the class of bug entirely.

On the retry policy: ON ERROR REPEAT 5 TIMES WAIT 3 is good practice, but be aware that with Fail on error status = Off, a 400 or 401 is not an error as far as PAD is concerned. The retry only covers transport-level failures — DNS, TLS, timeouts. That's actually what you want: retrying an invalid secret five times just delays the inevitable.

6.3 Parse the response

Variables.ConvertJsonToCustomObject Json: TokenResponse CustomObject=> TokenObject
SET AccessToken TO TokenObject['access_token']
SET AuthHeader TO $'''Bearer %AccessToken%'''
DateTime.GetCurrentDateTime.Local DateTimeFormat: DateTime.DateTimeFormat.DateAndTime CurrentDateTime=> CurrentDateTime
DateTime.Add DateTime: CurrentDateTime TimeToAdd: TokenObject['expires_in'] - 300 TimeUnit: DateTime.TimeUnit.Seconds ResultedDate=> TokenExpiry

The 300-second subtraction is a safety margin: the token is treated as stale five minutes before it actually expires, so a long-running Graph loop never hands over a token that dies mid-request.


7. The validation bug worth fixing

This is the pattern I see most often, and it reads perfectly plausibly until you trace it:

# INVERTED — do not use
IF TokenExpiry > CurrentDateTime THEN
    IF IsNotBlank(AccessToken) THEN
        Display.ShowMessageDialog.ShowMessage Message: $'''Token acquisition failed...'''
        EXIT FUNCTION
    END
END

Read it literally: if the token is still valid, and the token is not blank, declare failure and exit. The conditions describe the success case. On a genuine failure — blank AccessToken, TokenExpiry in the past — both conditions are false, nothing fires, and the subflow returns cleanly with an empty AuthHeader. Every downstream Graph call then fails with 401, and you go hunting for a permissions problem that doesn't exist.

The correct version validates on the status code first, then the payload:

IF TokenStatusCode <> 200 THEN
    Display.ShowMessageDialog.ShowMessage Message: $'''Token acquisition failed. Status: %TokenStatusCode%. Response: %TokenResponse%''' Icon: Display.Icon.Error Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> ButtonPressed
    EXIT FUNCTION
END

IF IsEmpty(AccessToken) THEN
    Display.ShowMessageDialog.ShowMessage Message: $'''Token endpoint returned 200 but no access_token was present.''' Icon: Display.Icon.Error Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> ButtonPressed
    EXIT FUNCTION
END

Guard clauses beat nested IFs here. Each condition describes one failure, exits immediately, and reads the way you'd describe it out loud.

For unattended runs, replace the message dialogs with a Write text to file logging action or a Set variable that the parent flow inspects — a modal dialog on an unattended machine hangs the run until the session times out.


8. Token caching

Since the token is valid for roughly an hour, don't request a new one for every Graph call. Put the cache check at the very top of the subflow:

DateTime.GetCurrentDateTime.Local DateTimeFormat: DateTime.DateTimeFormat.DateAndTime CurrentDateTime=> CurrentDateTime

IF IsNotBlank(AccessToken) AND TokenExpiry > CurrentDateTime THEN
    EXIT FUNCTION
END

# ... otherwise fall through and acquire a fresh token

Now every Graph action can call GetGraphToken unconditionally, and the subflow decides whether a network round trip is actually needed. Initialise AccessToken to an empty string in Main so the first check evaluates correctly.


9. Using the token

Web.InvokeWebService.InvokeWebService Url: $'''%GraphBase%/users?$top=10''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: %AuthHeader%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: False Encoding: Web.Encoding.utf_8 Response=> GraphResponse StatusCode=> GraphStatusCode
Variables.ConvertJsonToCustomObject Json: GraphResponse CustomObject=> GraphResult

AuthHeader already contains the Bearer prefix, so the custom header line is Authorization: %AuthHeader% — not Authorization: Bearer %AuthHeader%. Doubling the prefix is a classic 401.

Also note the $ in $top — PAD treats $'''...''' as a text literal, and $top inside it is fine, but if you build the URL through concatenation, watch for % characters in OData filters that need escaping as %%.

Microsoft's warning is worth repeating: don't decode or validate the token in your own code. Tokens for Microsoft services may use formats that won't parse as a standard JWT. If you want to confirm which roles were granted, make a real Graph call and read the response.


10. Troubleshooting

Symptom Likely cause Fix
AADSTS7000218: The request body must contain the following parameter: 'client_assertion' or 'client_secret' Encode request body left On, or the body is malformed Turn it Off in Advanced settings
AADSTS7000215: Invalid client secret is provided Secret ID pasted instead of secret Value; trailing whitespace; unencoded special characters Re-copy the Value; trim; percent-encode
AADSTS7000222: The provided client secret keys are expired Secret past its expiry date Create a new secret, or move to certificate credentials
AADSTS700016: Application with identifier '...' was not found in the directory Wrong client ID, or right client ID against the wrong tenant Verify both GUIDs come from the same app registration
AADSTS70011: The provided value for the input parameter 'scope' is not valid Malformed scope, or scopes from more than one resource Use exactly https://graph.microsoft.com/.default
Token issued (200) but Graph returns 403 Insufficient privileges Application permission added but admin consent never granted Grant admin consent in the API permissions blade
Token issued but Graph returns 401 Bearer prefix doubled, or AuthHeader empty because validation was inverted Check section 7
Failed to run PowerShell script — the system cannot find the file specified powershell.exe not on the PATH Add C:\WINDOWS\System32\WindowsPowerShell\v1.0\ to the system PATH (confirm with $PsHome)
Everything works interactively, fails unattended Credential Manager entry is per-user; or a modal dialog is blocking Use Persist LocalMachine, or switch to the Key Vault input-variable pattern; replace dialogs with file logging

A fast way to isolate PAD from Entra: reproduce the exact same POST in a REST client. If it succeeds there and fails in PAD, the problem is in the action's Advanced settings — nine times out of ten, Encode request body.


11. Before you ship

  • [ ] Delete every debug Display message action that renders the secret or the token, including disabled ones
  • [ ] Secret is retrieved at runtime, never stored in a Set variable action
  • [ ] Tenant ID and client ID are input variables, not hardcoded constants
  • [ ] Application permissions follow least privilege (User.Read.All over Directory.ReadWrite.All where it suffices)
  • [ ] Admin consent granted and recorded
  • [ ] Secret expiry tracked, with a rotation owner named
  • [ ] Failure paths write to a log file rather than a modal dialog
  • [ ] Token cache check is at the top of the subflow
  • [ ] Validation guard clauses tested by deliberately breaking the secret

References

No comments:

Post a Comment

Featured Post

Generating a Microsoft Graph Bearer Token in Power Automate Desktop (App ID + Secret)

Generating a Microsoft Graph Bearer Token in Power Automate Desktop (App ID + Secret) Power Automate Desktop has no native Microsoft Graph c...

Popular posts