Scripting a Microsoft login with pure Python requests
How to fully reproduce a Microsoft authentication flow (Azure AD and ADFS) using only the requests library. From the SAML redirect chain and reading $Config, to GetCredentialType, the sFT/sCtx flow tokens, and walking auto-submit SAML forms.
Nearly every enterprise portal you run into hands its login off to Microsoft. You click "sign in", and before you know it your browser bounces through a handful of redirects past login.microsoftonline.com and/or an ADFS server, only to land back on the original application. Great for users, awkward if you want to automate anything: there is no neat API token, just a series of HTML forms and cookies.
In this post I show how to reproduce that entire dance using only the requests library. No headless browser, no Selenium, no official SDK. Pure HTTP: scrape forms, read out tokens, and post forms back until you come out the other side at the application, logged in.
This is meant for your own accounts and systems you are authorized to touch. It is an exercise in understanding an auth protocol, not in bypassing someone else's security.
The big picture
A Microsoft login is not a single request but a chain. Roughly:
- You request a protected page. The application redirects you to Microsoft with a SAML request.
- Microsoft shows a login page. That page has no simple form, but a big chunk of JavaScript with a
$Configobject full of tokens. - You tell Microsoft who you are via
GetCredentialType. Microsoft then decides: do I handle this account myself (managed), or send you off to a corporate IdP like ADFS (federated)? - You submit your password, get a SAMLResponse back, and post that back to the application.
The trick is in steps 2 and 3: the tokens Microsoft hides in the page, and the branch between managed and federated.
Everything in one session
The whole flow leans on cookies. Every redirect sets a few, and you need all of them at the end. So you start with a single requests.Session that you carry through everything:
import requests
def build_session() -> requests.Session:
session = requests.Session()
session.headers.update({
# Pretend to be a real browser: some IdP endpoints reject
# requests without a believable User-Agent.
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
})
return session
One session to rule them all. As long as you reuse it, you never have to think about passing cookies around by hand.
Step 1: the SAML redirect chain
Your first GET on a protected URL almost never returns content directly. What you get is a 302 (or an auto-submit form) toward Microsoft, with a SAMLRequest parameter in the URL. requests follows redirects by default, so after a single GET you already land on the Microsoft login page:
resp = session.get(protected_url)
# resp.url is now something like:
# https://login.microsoftonline.com/<tenant>/saml2?SAMLRequest=...
Sometimes the handoff is not a real HTTP redirect but an HTML form that submits itself via JavaScript. These auto-submit forms come back later, so it pays to write a generic helper that reads out any form on a page:
import re
from html.parser import HTMLParser
from urllib.parse import urljoin
class FormParser(HTMLParser):
"""Grabs the first <form> on a page with all its fields."""
def __init__(self) -> None:
super().__init__()
self.action: str | None = None
self.method: str = "post"
self.fields: dict[str, str] = {}
def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]) -> None:
a = dict(attrs)
if tag == "form" and self.action is None:
self.action = a.get("action")
self.method = (a.get("method") or "post").lower()
elif tag == "input" and a.get("name"):
# Keep hidden fields too: SAMLRequest, AuthMethod, etc.
self.fields[a["name"]] = a.get("value", "")
def parse_form(html: str, base_url: str) -> tuple[str, dict[str, str]]:
p = FormParser()
p.feed(html)
action = urljoin(base_url, p.action or "")
return action, p.fields
Those hidden fields are gold. If you do not hand them back cleanly, the flow falls apart further down.
Step 2: reading the $Config object
The Microsoft login page renders the login form client-side. The server state lives in a big JavaScript object called $Config. It holds two crucial values:
sFT(flow token): an anti-forgery token you must include in every next step.sCtx(context): an opaque blob identifying the login session.
You cannot run JS with requests, so you fish the $Config block out of the HTML with a regex and parse it as JSON:
import json
import re
def extract_config(html: str) -> dict:
"""Pulls the $Config JSON object out of a Microsoft login page."""
match = re.search(r"\$Config\s*=\s*(\{.*?\});", html, re.DOTALL)
if not match:
raise RuntimeError("No $Config found, did the layout change?")
return json.loads(match.group(1))
config = extract_config(resp.text)
flow_token = config["sFT"]
ctx = config["sCtx"]
canary = config.get("canary") # extra CSRF-like token
post_url = config["urlPost"] # where the password will go
cred_url = config["urlGetCredentialType"]
The exact field names (sFT, sCtx, urlPost, canary) have been stable in the Microsoft login stack for years. Handy, because they form the backbone of everything that follows.
Step 3: GetCredentialType, managed or federated?
Before you submit a password, Microsoft wants to know who this is about. You POST your username as JSON to the GetCredentialType endpoint. The response tells you which way to go:
def get_credential_type(session, cred_url, username, flow_token):
payload = {
"username": username,
"isOtherIdpSupported": True,
"checkPhones": False,
"isRemoteNGCSupported": True,
"isCookieBannerShown": False,
"isFidoSupported": True,
"originalRequest": ctx,
"flowToken": flow_token,
}
r = session.post(cred_url, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"Origin": "https://login.microsoftonline.com",
"Referer": resp.url,
})
return r.json()
info = get_credential_type(session, cred_url, username, flow_token)
The interesting part of the response is Credentials:
creds = info.get("Credentials", {})
if creds.get("FederationRedirectUrl"):
# FEDERATED: this account belongs to a corporate IdP (ADFS).
# Microsoft does not handle the password itself, it redirects.
federation_url = creds["FederationRedirectUrl"]
saml_response = do_adfs_flow(session, federation_url, username, password)
else:
# MANAGED: Microsoft validates the password itself (Azure AD).
saml_response = do_managed_flow(
session, post_url, username, password, flow_token, ctx, canary
)
This branch is the heart of the whole story. Many organizations federate to their own ADFS; others let Azure AD check the password itself. Your script needs to handle both.
Step 4a: the managed flow (Azure AD)
For a managed account you simply post your password back to the urlPost from $Config, along with the flow token and context:
def do_managed_flow(session, post_url, username, password, flow_token, ctx, canary):
payload = {
"login": username,
"loginfmt": username,
"passwd": password,
"ctx": ctx,
"flowToken": flow_token,
"canary": canary,
"LoginOptions": 3, # "keep me signed in" behavior
"type": 11,
}
r = session.post(post_url, data=payload, headers={
"Origin": "https://login.microsoftonline.com",
"Referer": resp.url,
})
# MFA? Then you get a KMSI or proof-up page instead of the SAMLResponse.
if any(m in r.text for m in ("BeginAuth", "ProcessAuth", "SAS/ProcessAuth")):
raise MfaChallengeError("Account requires MFA, requests stops here.")
return finish_saml_walk(session, r)
This exposes the biggest limitation of the pure-requests approach right away: you cannot script MFA. The moment there is a phone prompt or authenticator push in the flow, it is over. The practical workaround is to log in once through a real browser (Playwright, for instance), save the cookies, and reuse those in your requests.Session.
Step 4b: the federated flow (ADFS)
If Microsoft sends you to a corporate IdP, you land on an ADFS login page. That works with a plain HTML form, no $Config. You detect the fields with regex patterns (because ADFS often uses its own names like UserName and Password) and post them back:
def do_adfs_flow(session, federation_url, username, password):
page = session.get(federation_url)
action, fields = parse_form(page.text, page.url)
# ADFS often names its fields slightly differently: match on pattern.
for name in fields:
if re.search(r"user|email|login", name, re.I):
fields[name] = username
elif re.search(r"pass", name, re.I):
fields[name] = password
# AuthMethod and other hidden fields stay exactly as they were.
r = session.post(action, data=fields, headers={"Referer": page.url})
return finish_saml_walk(session, r)
Note that we leave the hidden fields (like AuthMethod) untouched. We only fill the username and password fields; everything else gets posted back exactly as the server delivered it.
Step 5: the SAML form walk back home
Whether you went through managed or federated, the end result is the same: a page with a hidden form containing a SAMLResponse, which would normally submit itself via JavaScript. Since we do not run JS, we have to walk these auto-submit forms by hand, until we come out at the application again:
def finish_saml_walk(session, resp, max_hops: int = 5):
"""Follows auto-submit SAML forms until we are back at the app."""
for _ in range(max_hops):
action, fields = parse_form(resp.text, resp.url)
# No more form with SAML fields? Then we are home.
has_saml = any(k in fields for k in ("SAMLResponse", "SAMLRequest"))
if not action or not has_saml:
return resp
resp = session.post(action, data=fields, headers={"Referer": resp.url})
raise RuntimeError("SAML walk got stuck, too many hops.")
Each hop posts the SAMLResponse form to the next party, which sets another cookie and sometimes returns yet another form. After a few rounds you drop out of the loop as soon as the page no longer contains a SAML field: at that point your session is fully authenticated, and you can call the application's protected endpoints with the same session as if you were a regular user.
What to take away
- A Microsoft login is a chain of HTML forms and cookies, not a single API call. Once you understand the chain, you can reproduce it entirely with
requests. - The two tokens that hold everything together are
sFT(flow token) andsCtx(context), hidden in the$Configobject on the login page. GetCredentialTypeis the pivot: it decides whether you end up in the managed (Azure AD) or federated (ADFS) branch.- The final piece is always a SAML form walk: posting auto-submit forms by hand until you are back at the application.
- The hard limit is MFA. Once it is on, pure HTTP will not cut it and you have to log in through a browser once and reuse the cookies.
The nice part is that this approach needs zero dependencies beyond requests. No heavy browser automation, no brittle UI selectors. Just HTTP, a few regexes, and an understanding of the protocol.
Tags
python, requests, microsoft-auth, azure-ad, adfs, saml, single-sign-on, oauth, web-scraping, authentication, sso, http