Skip to main content

Nobody Should Be Typing Credentials Into Your Automations: Headless Microsoft Graph, Step by Step

Nick Ross3 min read

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:

  1. In the Entra ID Admin Center (opens in new tab), go to Applications > App Registrations > + New Registration
New registration option in the Entra ID app registrations blade
  1. 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
App registration form with display name and localhost redirect URI
  1. On the next screen, record the Application ID and Directory ID
Application and directory IDs on the app registration overview page
  1. Click into Certificates and Secrets > + New Secret
Creating a new client secret under certificates and secrets
  1. Add a description and expiration date
Client secret description and expiration settings
  1. Record your secret value. You will not be able to access it again after leaving this page.
Client secret value shown once after creation
  1. 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.
API permissions blade on the app registration

Example permissions:

Example Graph API permissions added to the app registration
  1. After the permissions are added, grant consent for your tenant. This action requires the Global Administrator role.
Grant admin consent button for the configured permissions

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:

powershell
# 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"$refreshToken

When the script prompts for an authorization code, grab the code parameter from the redirect URL:

Browser redirect URL containing the authorization code parameter

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

Isolating the code parameter from the session state in the redirect URL

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

Script output showing the access token and refresh token

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:

powershell
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:

License report generated from Graph API data

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
Nick Ross

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.

Keep reading