Nobody Should Be Typing Credentials Into Your Automations: Headless Microsoft Graph, Step by Step
TL;DR
- Headless automation against a Microsoft tenant runs on an Entra ID app registration: an identity for your scripts that authenticates without anyone sitting at a terminal.
- Calling the Graph API directly also covers the many endpoints that have no PowerShell cmdlet equivalent.
- A refresh token is valid for 90 days and should be stored somewhere very safe; access tokens minted from it expire after one hour.
- Granting admin consent for API permissions requires the Global Administrator role, and the app secret value is visible only once at creation.
- If an automation fails, check that the app registration's granted permissions match what the Graph documentation requires for that specific API call.
An automation that needs a person to authenticate it is a task with extra steps. The goal for any recurring job against a Microsoft tenant is headless execution: a service authenticates on its own, calls the Microsoft Graph API, and does the work with nobody in front of a terminal window. The same plumbing pays off in another way, too. Plenty of Graph API endpoints have no PowerShell cmdlet equivalent, so scripts that hit the API directly can reach functionality the Graph cmdlets simply do not cover.
This guide builds that plumbing for a single tenant using a service principal (an app registration) in Entra ID: create the identity, grant it permissions, mint the tokens, and call Graph from a script. If you are an MSP looking to run automations across the tenants you manage in Partner Center, the GDAP version of this problem is covered separately in My Automations Break with GDAP: The Fix!
Step one: create the service principal
The app registration is the identity your future API calls will map to. Prefer to script the creation? Use this PowerShell: Secure-App-Model/Create-App-Registration (opens in new tab). Note that if you go the scripted route, you still need to grant the permissions to the tenant as described in step 8 below.
The portal walkthrough:
- In the Entra ID Admin Center (opens in new tab), go to Applications > App Registrations > + New Registration

- Provide a display name (we recommend a name with no spaces). Add a redirect URI of https://localhost:8400 (opens in new tab) and click Register

- On the next screen, record the Application ID and Directory ID

- Click into Certificates and Secrets > + New Secret

- Add a description and expiration date

- Record your secret value. You will not be able to access it again after leaving this page.

- Click on API Permissions and add the permissions your automations will need against this tenant. The Microsoft Graph API documentation (opens in new tab) lists which permissions each API call requires.

Example permissions:

- After the permissions are added, grant consent for your tenant. This action requires the Global Administrator role.

Step two: mint a refresh token
The refresh token is what makes the setup headless: it can produce access tokens for the authentication headers on your Graph API calls. A generated refresh token is good for 90 days and belongs in a very safe storage location.
Run the following PowerShell to generate an access token and refresh token, replacing the variables at the top with the app registration values recorded earlier:
# Define your application (client) ID, secret, redirect URI, and tenant ID
$clientID = "YOUR_CLIENT_ID"
$clientSecret = "YOUR_CLIENT_SECRET"
$tenantID = "YOUR_TENANT_ID"
$redirectUri = "http://localhost:8400"
$scope = "https://graph.microsoft.com/.default"
# Step 1: Open the browser for user to authenticate and grant permissions
$authUrl = "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/authorize?client_id=$clientID&response_type=code&redirect_uri=$redirectUri&response_mode=query&scope=https://graph.microsoft.com/.default"
Start-Process $authUrl
# Manual step: After the user authenticates, they will be redirected to the provided redirectUri with the authorization code in the query string.
# Capture this code manually.
$authorizationCode = Read-Host "Please enter the authorization code from the URL"
# Step 3: Exchange the authorization code for tokens
$body = "grant_type=authorization_code&client_id=$clientID&client_secret=$clientSecret&code=$authorizationCode&redirect_uri=$redirectUri&scope=$scope"
$headers = @{ 'Content-Type' = 'application/x-www-form-urlencoded' }
$tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/token"
$response = Invoke-RestMethod -Method POST -Uri $tokenEndpoint -Body $body -Headers $headers
$accessToken = $response.access_token
$refreshToken = $response.refresh_token
# Output the tokens
Write-Host -ForegroundColor Green "Access token is" $accessToken
Write-Host -ForegroundColor Blue "Refresh token is"$refreshTokenWhen the script prompts for an authorization code, grab the code parameter from the redirect URL:

As a best practice, copy the entire string into Notepad first, then extract only the code parameter, not the session state:

The output gives you an access token and a refresh token. Store the refresh token in a safe location.

Step three: trade the refresh token for fresh access tokens
The access token only lives for an hour, so every automation run starts by exchanging the stored refresh token for a new one:
function Get-AccessToken {
param (
[Parameter(Mandatory=$true)]
[string]$clientID,
[Parameter(Mandatory=$true)]
[string]$clientSecret,
[Parameter(Mandatory=$true)]
[string]$tenantID = "common", # Your tenantID
[Parameter(Mandatory=$true)]
[string]$refreshToken, # Your refreshToken
[string]$scope = "https://graph.microsoft.com/.default" # Default scope for Microsoft Graph
)
# Token endpoint
$tokenUrl = "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token"
# Prepare the request body
$body = @{
client_id = $clientID
scope = $scope
client_secret = $clientSecret
grant_type = "refresh_token"
refresh_token = $refreshToken
}
# Request the token
$response = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType "application/x-www-form-urlencoded" -Body $body
# Return the access token
return $response.access_token
}Step four: call Graph with the access token
The access token goes into the request headers of your Graph API calls to authenticate them. For a working example, this sample script takes an access token and hits two Graph API endpoints to generate a license report for the tenant: License Report PowerShell (opens in new tab)
The output:

What breaks, and where to look first
With the fundamentals in place, the remaining failure mode is almost always permissions: your scripts can only get a working access token for API calls the app registration has been granted. When an automation errors out, compare the permissions on the app registration against what the Graph documentation requires for the exact call you are making, grant what is missing, re-consent, and run it again.
Frequently asked questions
When should you call Graph directly instead of using the PowerShell Graph cmdlets?
Whenever the automation must run headless, and whenever the API you need has no corresponding cmdlet. Many Graph API calls today have no cmdlet equivalent, so direct REST calls are the only scriptable path.
How long do the tokens last?
The refresh token is good for 90 days from generation. Access tokens derived from it last one hour, which is why automations regenerate them from the stored refresh token on each run.
Does this work across all the tenants an MSP manages?
This pattern targets a single tenant. Multi-tenant automation through Partner Center and GDAP uses the same building blocks but needs a different consent architecture.
The audit your scripts were building toward, already built
Service principals, token handling, report scripts: that scaffolding has one usual destination, tenant visibility. CloudCapsule delivers it ready-made: 250+ security controls assessed per tenant in about 60 seconds.
Run a free scan
Written by
Nick Ross
CEO · Microsoft MVP · Founder, T-Minus 365
Nick is not just a CEO, he's a respected thought leader and influencer in the MSP space. Tens of thousands of MSPs learn through his YouTube channel, T-Minus365. Nick has been honored as a three-time Microsoft MVP for his educational content; his expertise and influence are the backbone of our mission, ensuring that you are in the best hands when it comes to security.
Nick joined Pax8 in 2017, where he would ultimately oversee product management for PSA and Microsoft integrations. Following his tenure at Pax8, Nick has continued to demonstrate his leadership prowess as an executive at various MSPs, culminating in his most recent role at Sourcepass.
Nick holds a Bachelor's Degree in Business Management from Florida State University, as well as a Minor Degree in Entrepreneurship. In his free time, Nick is an avid hiker, reader, and fitness-junkie.


