Identity for web developers · a self-contained chapter
Single Sign-On with Entra ID
How one sign-in at your company's identity provider opens every internal app, why that is safe, and what you build to make it happen. The chapter starts from HTTP cookies, goes through OpenID Connect, and ends with a working setup on Microsoft Entra ID.
How to read this chapter
The chapter has four parts, and each section builds on the ones before it. Part I covers the web features that single sign-on is made of: cookies, redirects and digital signatures. Part II puts them together into the protocol, OpenID Connect. Part III is about Microsoft Entra ID itself. Part IV is your project: architecture, containers, a lab on your Mac, and a plan.
The figures marked Interactive run in this page and use your browser's real cryptography, so a signature that fails in them is a real failure. Each section ends with two or three questions under Check yourself. At the end there are review questions, flashcards and a glossary.
Four things keep the same colour in every figure: the browser (slate), your app (teal), the identity provider (indigo) and keys and secrets (amber).
What you will be able to do
- Explain the difference between authentication and authorization, and what an identity provider does for your apps.
- Describe how cookies, redirects and sessions let a browser carry a sign-in from one site to another.
- Explain what a digital signature proves, and check a token's signature yourself.
- Walk through the OpenID Connect authorization code flow, and say which attack each of its parameters stops.
- Name the Entra ID objects your project needs (tenant, app registration, enterprise application, redirect URI, credential, app roles) and who in your company can create them.
- Choose how to add sign-in to several internal apps: a library in each app, a sign-in proxy in front of them, or the AWS load balancer.
- Run a complete single sign-on setup on your Mac, read every step in its logs, and plan the move to the real Entra ID.
Figure 0.1 · How the ideas connectInteractive
Select a box to see what it is, what it connects to, and where the chapter explains it.
Part I
Foundations
The problem single sign-on solves, and the four ordinary web features it is built from: HTTP requests, cookies, redirects and digital signatures.
1The problem single sign-on solves
Why you need thisEvery design choice later in the chapter answers one of the problems in this section. If you can name the problem, you can explain the choice to your team.
1.1A company without single sign-on
Picture a company with five internal web apps: an expense tracker, a model playground, a wiki, a support dashboard and an analytics site. Each one was built by a different team, and each one has its own sign-in page, its own table of users and its own stored passwords.
That causes problems for everyone involved:
- For users, five passwords. People reuse them or write them down, and the help desk resets them all day.
- For developers, five copies of the same hard code: storing passwords safely, reset emails, lockout after failed attempts, two-step verification. Each copy is a new chance to get it wrong.
- For security, five places a password database can leak from. When someone leaves the company, somebody has to remember to remove five accounts, and the one that is forgotten stays open.
- For audits, no single place that answers "who used what, and when?".
Figure 1.1 · Alice leaves the companyInteractive
1.2One identity provider for every app
Single sign-on moves the whole job of "who is this person?" out of the apps and into one service, the identity provider, or IdP. The apps no longer have passwords. When someone opens an app, the app sends them to the identity provider; the identity provider checks who they are and sends them back with a signed statement that says, in effect, "this is Alice, I checked, and this statement is for you".
An app that trusts an identity provider in this way is called a relying party. Your apps will be relying parties, and Microsoft Entra ID will be the identity provider. Entra ID is the identity service Microsoft runs for companies; if your company uses Microsoft 365 (Outlook, Teams), it already has Entra ID with every employee in it.
This arrangement gives you, at once:
- One set of credentials per person. Passwords are typed only on the identity provider's page. Your apps never see them, so they cannot leak them.
- One place for policies. Two-step verification, blocked countries, "only from company laptops": all of it is set up once, in Entra, and applies to every app.
- One switch to turn a person off. Disable the account in Entra and no app will let them sign in again.
- One log. Entra records every sign-in to every app.
And it gives users the thing the name promises: single sign-on. Once Alice has signed in for the expense tracker, the wiki lets her in without asking again, because the identity provider remembers her. Section 6.4 shows exactly how it remembers.
You will also meet the word federation. It means an agreed trust between separate systems: your app agrees to believe what Entra signs. "Federated sign-in" and "single sign-on" are often used for the same thing.
1.3Two different questions: authentication and authorization
Every request to a protected app has to answer two questions, in this order.
- Authentication: who are you? The user proves their identity with a credential: a password, a passkey, a code from the Microsoft Authenticator app. The result is an identity: "this is Alice, employee, account
aaaaaaaa-…". - Authorization: what may you do? The app looks at that identity, plus rules (Alice is an Admin in the expense tracker, Bob is a Reader), and decides whether to allow the action.
Single sign-on is mostly about the first question. Entra can carry information for the second one too, such as the roles you assign in it (§9), but the decision, "may Bob open the admin page?", is made in your code.
HTTP has a status code for each failure, and their names are a famous historical mix-up:
| Status | Official name | What it really means | Example |
|---|---|---|---|
401 | Unauthorized | Not authenticated: "I don't know who you are." | An API call with no token |
403 | Forbidden | Authenticated, but not authorized: "I know who you are, and the answer is no." | Bob opens the admin page |
Key idea
Authentication says who someone is. Authorization says what they may do. An identity provider does the first for all your apps; each app still does the second.
Check yourself
Bob signs in through Entra without trouble, then sees "You don't have access to this report". Which question failed?
With single sign-on, where does a user type their password?
2How the web remembers you
Why you need thisSingle sign-on is built from four ordinary web features: HTTP requests, cookies, sessions and redirects. Once you know them, the protocol in §6 is just these features in a particular order.
2.1Each HTTP request stands alone
When your browser loads a page, it sends an HTTP request to a server, and the server sends back a response. The request has a method (GET to read, POST to send a form), a URL, and headers: short Name: value lines with extra information. The response has a status code (200 means OK), headers, and a body, usually the page itself.
Here is a real response from the lab you will run in §13. The command curl -i prints the response headers as well as the body:
curl -si http://app-a.localhost:8001/login
Output (long values shortened with …)
HTTP/1.0 302 Found Server: BaseHTTP/0.6 Python/3.12.15 Date: Wed, 14 Oct 2026 22:05:56 GMT Location: http://idp.localhost:9000/0f1e…/oauth2/v2.0/authorize?client_id=a000…&redirect_uri=…&state=… Set-Cookie: login_state=…; Path=/auth; Max-Age=600; HttpOnly; SameSite=Lax Content-Length: 0
Two of these lines are the heart of single sign-on, and the rest of this section explains them: Location (a redirect, §2.4) and Set-Cookie (a cookie, §2.2).
The important property of HTTP is that the server does not remember anything between requests. Request number two arrives with no memory of request number one. HTTP is stateless. Anything that has to be remembered, such as "this browser belongs to Alice", must be carried in the requests themselves.
| Status | Meaning | Where you will see it in this chapter |
|---|---|---|
200 | OK, here is the page | Every page that loads normally |
302 | Found, go to the URL in Location | Every hop of a sign-in |
400 | Bad request | Entra refusing a broken sign-in request |
401 | Not signed in | An API call without a valid token or session |
403 | Signed in, but not allowed | A user without the required role |
2.2Cookies: the server's note in your browser
A cookie is how a server gets the browser to carry something for it. The server sends a Set-Cookie header; the browser stores the value and, from then on, adds it to every request to that same server in a Cookie header:
# the server's response Set-Cookie: session=Qm9iJ3Mgc2Vzc2lvbg; Path=/; HttpOnly; Secure; SameSite=Lax # every later request from the browser to the same host Cookie: session=Qm9iJ3Mgc2Vzc2lvbg
The fact that matters most for this chapter: a cookie belongs to the host that set it. A cookie set by expenses.company.com is sent only to expenses.company.com. The browser never sends it to wiki.company.com, and never to login.microsoftonline.com, which is Entra's sign-in host. One site cannot read another site's sign-in. That is good for security, and it is the reason single sign-on needs the redirect trick in §2.4.
The words after the value are attributes. They tell the browser how to treat the cookie, and each one blocks a particular attack:
| Attribute | What it tells the browser | What it protects against |
|---|---|---|
HttpOnly | Don't let JavaScript on the page read this cookie | A malicious script (injected into the page) copying the session cookie |
Secure | Send it only over HTTPS | Someone on the network reading the cookie |
SameSite=Lax | Don't send it when another site causes the request, except when the user follows a link to this site | Other sites making your browser do things in your name (cross-site request forgery) |
SameSite=Strict | Never send it when another site causes the request, not even for links | The same, more strictly; it also breaks the return from a sign-in, so it is not used for sessions |
SameSite=None | Always send it (requires Secure) | Nothing; needed only when another site must post to yours (§6.3) |
Max-Age / Expires | Keep it for this long; without them, delete it when the browser closes | Sessions that last forever |
Path | Send it only for URLs under this path | Sending a cookie to pages that don't need it |
2.3Sessions: a random number that stands for you
A session is the server remembering "this browser is Alice" for a while. The usual way to build one: after Alice signs in, the server makes a long random value (the session ID), stores {session ID → Alice} on its side, and sends the session ID to the browser in a cookie. Every later request carries the cookie, and the server looks it up.
There are two common designs, and you will choose between them in §12.3:
| Server-side session | Cookie session | |
|---|---|---|
| The cookie holds | Only a random ID | The data itself (who the user is), signed by the server so it cannot be changed |
| The server stores | The data, in memory, Redis or a database | Nothing |
| End one session early | Yes: delete the record | No: it is valid until it expires |
| Several copies of the app | Need a shared store (Redis) | Work, if they share the signing key |
| Size limit | None | About 4 KB per cookie |
The lab's apps use server-side sessions kept in memory, the simplest design. Section 12.3 explains why that design breaks as soon as you run two copies behind a load balancer.
2.4Redirects: how one site sends you to another
A redirect is a response that says "go there instead": status 302 and a Location header with a new URL. The browser follows it immediately, without the user doing anything. You saw one in §2.1: the lab's app answered /login by sending the browser to the identity provider.
Redirects can carry data, in the query string of the URL. Characters that have a meaning in URLs are URL-encoded (written as % plus two hex digits): : becomes %3A, / becomes %2F, and a space becomes + or %20. This is how an app tells the identity provider who it is and where to send the user back, and how the identity provider sends its answer. Take apart the real URL from the lab:
Figure 2.1 · Take a URL apartInteractive
redirect_uri tells the identity provider where to send the browser back.2.5Front channel and back channel
A sign-in uses two kinds of connections, and the difference decides what may travel where:
- The front channel is everything that passes through the browser: redirects and forms. These messages are visible. They end up in the browser history, in server logs, and sometimes in other software on the user's computer, and the user (or malware) can change them.
- The back channel is a direct HTTPS call from one server to another, for example from your app's server to Entra. The browser is not involved and never sees it.
A safe design sends only short-lived, single-use values that are useless on their own through the front channel, and sends everything valuable (tokens and secrets) through the back channel. You will see this rule applied in step 7 of §6.
2.6HTTPS, and why localhost is the exception
HTTPS encrypts the connection and proves, with a certificate, that the browser is talking to the real server. In single sign-on, codes, tokens and session cookies all travel over these connections, so in production everything must be HTTPS. Entra enforces this: it accepts plain http:// return addresses only for localhost, because traffic to localhost never leaves your computer. The lab uses plain HTTP for that reason; nothing in it would be acceptable on a real server.
Check yourself
The expense tracker at expenses.company.com sets a cookie session=xyz. Is that cookie sent to login.microsoftonline.com?
Why should a session cookie have the HttpOnly attribute?
Which of these should never travel through the front channel?
3Proving things without sharing secrets
Why you need thisThe identity provider sends your app a statement: "this is Alice". Your app must be sure that Entra wrote it and that nobody changed it on the way. That proof is a digital signature, and every token check in §4 and §7 relies on this section.
3.1Hash functions: a fingerprint of any data
A hash function turns any amount of data into a short, fixed-size value, its hash. This chapter uses SHA-256, which always produces 256 bits, shown as 64 hexadecimal characters. Hash functions have four properties that everything later depends on:
- The same input always gives the same hash.
- A tiny change in the input (one letter) gives a completely different hash.
- You cannot work backwards from a hash to the input.
- Nobody can find two different inputs with the same hash.
Try properties 1 and 2. Change one letter and watch the whole hash change:
Figure 3.1 · SHA-256 in your browserInteractive
crypto.subtle.digest). "Alice Example" and "alice Example" have nothing in common.3.2Encoding is not encryption: base64url
Tokens and hashes are bytes, and bytes cannot travel safely in URLs or headers. Base64 writes any bytes using 64 ordinary characters (A–Z, a–z, 0–9 and two more). Base64url is the variant made for URLs: it uses - and _ instead of + and /, and drops the = padding at the end.
Base64url hides nothing. It is an encoding, not encryption, and anyone can turn it back into the original. The text eyJhbGciOiJSUzI1NiJ9 is just {"alg":"RS256"}. Remember this in §4: anyone who holds a token can read what it says.
Figure 3.2 · Decode base64urlInteractive
3.3Digital signatures with a key pair
A digital signature uses a key pair: two very large numbers that are mathematically linked. The algorithm Entra uses is called RSA. One number of the pair, the private key, stays secret inside Entra. The other, the public key, is published for everyone.
- Signing (only the private key can do it): compute the SHA-256 hash of the message, then transform the hash with the private key. The result is the signature, 256 bytes for a 2048-bit key.
- Verifying (anyone can do it, with the public key): transform the signature back with the public key and compare the result with your own SHA-256 hash of the message. They match only if the message is unchanged and the signature was made with the matching private key.
So a valid signature proves two things: who made the message (whoever holds the private key) and that it was not changed. It does not hide the message, which stays readable.
Try it with a real key pair, made in your browser:
Figure 3.3 · Sign and verify (RSA, 2048 bits, SHA-256)Interactive
Admin to admin) and verify again. This is the same algorithm (RS256) that Entra uses for ID tokens.Compare this with a shared secret, such as the client secret your app will use in §6. Both sides know the same value, so it proves only "the sender knows the secret", and anyone who can check it can also forge it. That is why Entra signs tokens with a private key: every app can check Entra's signatures with the public key, and no app can make one.
3.4Publishing public keys: JWKS, kid and key rotation
Entra publishes its public keys as a JSON Web Key Set (JWKS) at a fixed URL. Each key has a key ID, kid. Every token says, in its header, which kid signed it, so the app knows which key to check it with. This is the first key from Entra's real key set, fetched on 14 October 2026 (two minor fields, x5t and cloud_instance_name, left out):
curl -s https://login.microsoftonline.com/common/discovery/v2.0/keys | jq '.keys | length'
9
{
"kty": "RSA", key type
"use": "sig", used for signatures
"kid": "SNaVJx8A_IYwib6ITN46_ejclXw", key ID
"n": "od8oLPkg6Gb8m3r1pOBukfonwpZ7Rg5d6jdc1Kds…", the public key (modulus), shortened
"e": "AQAB", the public exponent, 65537
"x5c": ["MIIC/…"], the same key as a certificate
"issuer": "https://login.microsoftonline.com/{tenantid}/v2.0"
}
Entra changes its signing keys from time to time, without warning. This is key rotation. Your app must therefore never copy a key into its code or configuration. It reads the key set from the URL, keeps it in memory, and reads it again when a token arrives with a kid it has not seen. Every maintained library does this for you. In the lab you will restart the identity provider, which makes it create a new key, and watch the app fetch the new key set by itself (§13, task 9).
Check yourself
Entra's public keys are on a public URL. Why can't an attacker use them to make a fake token?
A token is signed, and someone changes "Reader" to "Admin" inside it. What does your app see?
4Tokens and the JSON Web Token format
Why you need thisEverything Entra tells your app arrives in a token. If you can read a token, you can debug almost any sign-in problem yourself.
4.1What a token is
A token is a piece of data an app receives as proof of something. Most tokens in this chapter are bearer tokens: whoever holds one can use it, with no further check of who they are, the way a banknote works for whoever carries it. That is why tokens travel only over HTTPS, are never written to logs, and expire after an hour or so.
4.2The three parts of a JWT
Entra's tokens use the JSON Web Token format, JWT. A JWT is three base64url texts separated by dots. This is a real one, issued by the lab's identity provider to App A on 14 October 2026:
- Header: how the token is signed. Decoded:
{"typ":"JWT","alg":"RS256","kid":"scdKoWJjUq1fkDDRJ_gq8befig5"}.RS256means RSA with SHA-256 (§3.3);kidsays which key (§3.4). - Payload: the statements, called claims.
- Signature: the RSA signature over the text
header.payload(the first two parts with the dot between them).
The payload, decoded:
{
"aud": "a0000000-0000-4000-8000-00000000000a", audience: the app this token is for
"iss": "http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/v2.0", issuer
"iat": 1792015543, issued at: 22:05:43 UTC
"nbf": 1792015543, not valid before
"exp": 1792019143, expires: one hour later
"name": "Alice Example",
"oid": "aaaaaaaa-1111-4111-8111-111111111111", the user's permanent ID
"preferred_username": "alice@lab.test",
"sid": "f98ed202-e57e-499d-9af6-3242eb1fe2cd", the identity provider's session
"sub": "ZZ0Rz5aHSwXT1C3s7yKz5mx2c4GB1ICHZsRkI97SJ_A", subject, different in every app
"tid": "0f1e2d3c-4b5a-4697-8877-665544332211", tenant
"ver": "2.0",
"nonce": "iPQ6XyFyzHEjlK32HGsFyQ", ties the token to one sign-in (§7.4)
"roles": ["Admin"] app roles (§9.2)
}
Times in tokens are Unix timestamps, counted in seconds since 1 January 1970 UTC. Section 9.5 and Figure 9.1 go through every claim that Entra sends. For now, notice that the three short names iss, aud and exp already answer "who made this, who is it for, and is it still valid?"
4.3Check a token yourself
The workbench below does exactly what your app does when a token arrives (the full list is in §7.11). It comes loaded with the lab token above and the public key the lab published at the same time. Run the checks, then edit one letter of the payload and run them again.
Figure 4.1 · Token workbenchInteractive
crypto.subtle.verify with RSASSA-PKCS1-v1_5 and SHA-256, the same check as the lab's app and as libraries such as MSAL. In the lab (§13, task 8) you can paste a fresh token and key set from your own Mac.4.4Signed is not encrypted
Because the payload is only base64url, anyone who holds a token can read it, exactly as you just did. Never put secrets in a token, and treat what you read in one as visible. Signing guarantees who wrote the claims and that they were not changed; it does not hide them.
Careful with real tokens
Websites that decode tokens send what you paste to their servers, or could. A real token from your company is a working credential until it expires. Decode real tokens only with tools that run on your own machine, like the workbench above (this page sends nothing anywhere) or a line of Python.
4.5Three kinds of tokens
OpenID Connect and OAuth use three kinds of tokens. They are compared in full in §5.4; here is the short version:
- The ID token is for your app. It says who signed in. It is always a JWT.
- An access token is for an API. Whoever holds it may call that API, within the permissions it lists.
- A refresh token is for the identity provider. It lets your app get new tokens later without asking the user again.
Check yourself
Which part of a JWT tells your app which public key to use?
Is it safe to put a database password into a claim, since the token is signed?
Part II
The protocols
OAuth 2.0 and OpenID Connect: what each one is for, the exact sequence of a sign-in, and the attack that each step of that sequence prevents.
5OAuth 2.0 and OpenID Connect
Why you need thisSingle sign-on with Entra uses OpenID Connect, which is built on top of OAuth 2.0. Many confusing words in Microsoft's documentation (scope, consent, client, grant) come from OAuth. Knowing which idea comes from which standard makes the documentation readable.
5.1OAuth 2.0: letting an app act for you
OAuth 2.0 (published in 2012 as RFC 6749) solved a problem that has nothing to do with signing in. An app wants to use your data in another service, for example to read your calendar, without asking for your password to that service. OAuth's answer: the service gives the app an access token that allows only certain things, after you agree.
OAuth names four roles. Learn them, because the documentation uses them everywhere:
| OAuth role | What it is | In your project |
|---|---|---|
| Resource owner | The person whose data it is | The employee |
| Client | The app that wants access | Your web app |
| Authorization server | Checks the user and issues tokens | Entra ID |
| Resource server | The API that accepts access tokens | Microsoft Graph, or an API of your own |
The permissions are called scopes, for example User.Read ("read my profile") or Calendars.Read. Before the first token is issued, the user, or an administrator on behalf of the whole company, gives consent to those scopes. Many companies allow only administrators to consent (§8.6).
OAuth defines several ways to get a token, called grant types or flows:
- Authorization code: for a user in a browser. This is the one single sign-on uses, explained step by step in §6.
- Client credentials: an app acting as itself, with no user, for example a nightly job calling an API (§11.6).
- Refresh token: getting new tokens later, without the user.
- Device code: for devices without a usable browser, such as a command-line tool. The user signs in on their phone.
Two older flows are now discouraged: the implicit flow, which put tokens directly in the browser's URL, and the password flow, in which the app collects the user's password, which defeats the whole idea.
5.2Why an access token is not a sign-in
An access token says "the holder of this token may call this API". It does not say "Alice is using your app right now". Early sites used access tokens to sign users in anyway, and that caused real security holes:
- The token is meant for the API, and its format is the API's business. Microsoft's own tokens for Microsoft Graph can't even be checked by your app (§5.4).
- Nothing in it says which app it was issued to. An access token that some other app received could be replayed to yours, and your app would sign in whoever it named.
OpenID Connect (2014), OIDC for short, adds what was missing. It is OAuth 2.0 plus:
- the ID token: a signed JWT made for your app (its
audis your app's ID), saying who signed in, when, and for which sign-in attempt (nonce); - the
openidscope, which asks for an ID token, and theprofileandemailscopes, which ask for more claims; - a standard list of claims (
sub,name,email…); - the discovery document, a URL where an app reads all of the identity provider's settings (§5.5);
- the UserInfo endpoint and standards for sign-out (§10.2).
Key idea
OAuth 2.0 is about permission to call APIs. OpenID Connect adds sign-in on top: an ID token for your app. Single sign-on uses OpenID Connect.
5.3SAML 2.0, the older alternative
Before OpenID Connect there was SAML 2.0 (Security Assertion Markup Language, 2005). It does the same job with XML: the identity provider sends a signed XML document, the assertion, through the browser to the app. Many commercial services (Salesforce, Workday, older enterprise software) still use it, and Entra supports both. For apps you write yourself, use OpenID Connect: the messages are JSON, the libraries are simpler, and Entra offers the same features.
| SAML 2.0 | OpenID Connect | |
|---|---|---|
| Message format | XML assertion, XML signature | JSON Web Token |
| Name for your app | Service provider (SP) | Relying party (RP), or client |
| Settings published as | XML metadata file | Discovery document (JSON) |
| Can also call APIs | No | Yes, with OAuth access tokens |
| Groups in Entra tokens, at most | 150 | 200 |
| Typical use today | Commercial apps, older systems | New web apps, mobile apps, APIs |
5.4The three tokens in detail
Here are the three tokens from §4.5 with Entra's actual rules. The lifetimes come from Microsoft's documentation, checked on 14 October 2026.
| ID token | Access token | Refresh token | |
|---|---|---|---|
| For | Your app | An API | Entra's token endpoint |
| Proves | Who signed in, when, and for which app | The holder may call this API with these permissions | The app may get new tokens without the user |
| Format | JWT; your app always checks it | JWT for your own APIs; for Microsoft's APIs, treat it as unreadable | Unreadable string |
aud | Your app's client ID | The API's client ID | – |
| Default lifetime | 1 hour | Random between 60 and 90 minutes (75 on average); up to 28 hours when the client and the API both support Continuous Access Evaluation | Until 90 days without use (web apps); 24 hours for single-page apps |
| Ask for it with | Scope openid | The API's scope, such as User.Read | Scope offline_access |
| Goes to | Stays on your server | Authorization: Bearer … header of API calls | Only Entra's token endpoint |
Don't read Microsoft's access tokens
Microsoft's documentation warns against checking or reading tokens issued for APIs you don't own, such as Microsoft Graph: "Tokens for Microsoft services can use a special format that will not validate as a JWT". Your app checks its own ID tokens and access tokens for its own APIs, nothing else.
5.5Discovery: how an app learns about the identity provider
Your app needs several addresses: where to send the browser, where to swap codes for tokens, where the public keys are, where to sign out. It does not hard-code them. It reads them from the identity provider's discovery document, found by adding /.well-known/openid-configuration to the issuer's address. You can read Entra's yourself:
curl -s https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri, end_session_endpoint}'The table compares what that returned on 14 October 2026 with what the lab's identity provider returns. The names are identical, which is why the lab's app could talk to the real Entra without code changes (§14.5).
| Field | Entra (organizations) | The lab's Mini Entra |
|---|---|---|
issuer | https://login.microsoftonline.com/{tenantid}/v2.0 | http://idp.localhost:9000/0f1e…2211/v2.0 |
authorization_endpoint | …/organizations/oauth2/v2.0/authorize | …/0f1e…2211/oauth2/v2.0/authorize |
token_endpoint | …/organizations/oauth2/v2.0/token | …/0f1e…2211/oauth2/v2.0/token |
jwks_uri | …/organizations/discovery/v2.0/keys | …/0f1e…2211/discovery/v2.0/keys |
end_session_endpoint | …/organizations/oauth2/v2.0/logout | …/0f1e…2211/oauth2/v2.0/logout |
id_token_signing_alg_values_supported | ["RS256"] | ["RS256"] |
subject_types_supported | ["pairwise"] | ["pairwise"] |
response_modes_supported | query, fragment, form_post | query |
frontchannel_logout_supported | true | true |
The {tenantid} in Entra's issuer is a placeholder, because organizations means "any company". For an internal app you use your own tenant's address, https://login.microsoftonline.com/your-tenant-id/v2.0, and its discovery document names your tenant exactly. For example, Microsoft's own tenant's document gives the issuer https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0.
Check yourself
Which token should your web app use to decide who signed in?
Which scope makes Entra return an ID token?
6The authorization code flow, step by step
Why you need thisThis is the exact sequence that runs every time someone opens one of your apps. If you can draw it from memory, you can build it, debug it, and explain it to your team.
6.1The cast
Three parties take part. The browser, with the user in front of it. Your app, meaning your server (here, an expense tracker at expenses.company.com). And Entra ID at login.microsoftonline.com. The browser keeps a separate cookie jar for every host, and the figure shows each jar so you can see who remembers what.
Step through the figure. Each step shows the real message: the URLs are Entra's, and the log line underneath is what the lab printed at that moment (§13). Switch to Second app to see single sign-on: the same steps, with the password step gone.
Figure 6.1 · The sign-in, one message at a timeInteractive
6.2The same flow in words
The eleven steps of a first sign-in group into four phases:
- The app sends the browser away (steps 1–2). The user opens the app with no session cookie. The app makes three random values (
state,nonceand a PKCE verifier), remembers them for this browser, and redirects the browser to Entra's/authorizeaddress with its client ID, its return address and those values. - Entra signs the user in (steps 3–5). Entra checks that the client ID exists and that the return address is registered for it, shows its sign-in page, checks the password and the second factor, applies the company's policies, and sets its own session cookie. Then it redirects the browser back to the app with a single-use authorization code.
- The app swaps the code for tokens (steps 6–9). The app checks that the returned
stateis the one it gave this browser. Then its server calls Entra directly (back channel) with the code, its client secret and the PKCE verifier. Entra checks all three and returns an ID token, which the app checks (signature, issuer, audience, expiry, nonce). - The app starts its own session (steps 10–11). The app sets its own session cookie and shows the page. From now on Entra is not involved until the app's session ends.
Notice what travels where. The front channel (the browser) carries only the code, which is useless without the client secret and the PKCE verifier. The ID token travels in the back channel and never passes through the browser.
6.3Every parameter, explained
The request that sends the browser to Entra (step 2):
| Parameter | Example | What it is for |
|---|---|---|
client_id | a0000000-…-00000000000a | Which app is asking. Entra gives each app registration this ID (§8.3). |
response_type | code | "Send me an authorization code." This choice is what makes it the authorization code flow. |
redirect_uri | https://expenses.company.com/auth/callback | Where to send the browser back. It must exactly match a URI registered for this app (§7.2). |
scope | openid profile email | What the app asks for. openid means "an ID token"; profile and email add claims; offline_access adds a refresh token. |
state | random, e.g. L4fZ0dKVL13C… | Comes back unchanged. The app checks it to make sure this answer belongs to a sign-in this browser started (§7.3). |
nonce | random | Entra copies it into the ID token. The app checks it to make sure the token was made for this sign-in (§7.4). |
code_challenge | SHA-256 of the verifier, base64url | PKCE: a fingerprint of a secret only the app's server knows (§7.1). |
code_challenge_method | S256 | "The challenge is a SHA-256 hash." |
response_mode | query or form_post | How the answer comes back: in the URL (query), or as a form the browser posts to your app (form_post, which Microsoft recommends for web apps; see the note below). |
prompt | login, none, select_account, consent | Optional. login forces the password page even if Entra remembers the user; none fails instead of showing any page; select_account shows the account picker. |
login_hint, domain_hint | alice@company.com | Optional. Fill in the username, or skip the "which company?" step. |
The answer that comes back to your app (step 5) contains code and state, or, if something went wrong, error and error_description.
The back-channel request from your server to Entra's token endpoint (step 7) is a POST with these form fields:
| Field | What it is for |
|---|---|
grant_type=authorization_code | "I am swapping a code." |
code | The code from step 5. Single use, and it expires after about a minute. |
redirect_uri | The same value as in step 2. Entra checks they match. |
client_id | The app's ID again. |
client_secret (or client_assertion) | Proves the caller really is this app: a shared secret, or a short JWT signed with the app's certificate (§8.4). |
code_verifier | The PKCE secret whose fingerprint was sent in step 2. |
The answer (step 8) is JSON: token_type (always Bearer), expires_in (seconds, for the access token), scope, id_token, access_token, and refresh_token if offline_access was asked for.
Going deeperQuery or form_post, and the SameSite trap
With response_mode=query, Entra's answer is a redirect, so the browser arrives at your callback with a normal GET. Your login cookie (the one holding state) has SameSite=Lax, and Lax cookies are sent on such top-level navigations, so everything works. The lab uses this mode.
With response_mode=form_post, Entra returns a small page that posts a form to your callback. Microsoft recommends this for web apps: the code does not appear in the URL, and long answers are not cut off by the 2,048-character URL limit. But that is a cross-site POST, and the browser does not send SameSite=Lax cookies with it. If your app kept state in a Lax cookie, the callback arrives without it and the sign-in fails with "state does not match". Apps that use form_post must set that one cookie to SameSite=None; Secure. Libraries handle this when configured for form_post; it is a common cause of failures with hand-written code.
6.4Where the "single" in single sign-on comes from
Look at the cookie jars at the end of a first sign-in. There are two new cookies, on two different hosts:
login.microsoftonline.comhas Entra's own session cookie, set in step 4 when Alice typed her password.expenses.company.comhas the expense tracker's session cookie, set in step 10.
Now Alice opens the wiki, a second app. The wiki has no cookie, so it redirects her browser to Entra, exactly like step 2. But this request goes to login.microsoftonline.com, so the browser attaches Entra's cookie to it (§2.2: cookies go to the host that set them). Entra recognises Alice from that cookie and skips the sign-in page entirely. It immediately redirects her back to the wiki with a new code, and steps 6–11 repeat for the wiki. The user sees one or two quick page changes and is signed in.
Key idea
Single sign-on is the identity provider's own session cookie, plus redirects. Each app still runs the full flow and gets its own token and its own session; only the password step disappears.
In the lab
This is the identity provider's log line when App B sent the browser over, right after App A's sign-in:
[mini-entra] /authorize from App B: this browser already has an IdP session for alice@lab.test -> no sign-in page. This is single sign-on.
Three practical consequences:
- Single sign-on works per browser. A different browser, or a private window, has no Entra cookie, so the user signs in again.
prompt=loginmakes Entra ignore its cookie and ask again, for example before a sensitive action.- On company-managed Windows PCs and Macs, Entra can also recognise the user from the device sign-in itself, so many employees never see a password page at all. The flow your app runs is the same.
6.5After the sign-in: the app's own session
After step 10, Entra is out of the picture. The app's session cookie is what keeps Alice signed in to the app, and the app alone decides how long that lasts. A common choice is to match the ID token's lifetime, one hour, as the lab does; another is a working day. When the app session ends, the app simply sends the browser to Entra again. While Entra's own session is still valid, that round trip takes a fraction of a second and needs no password. Section 10 explains how to choose these lifetimes, and why shorter app sessions make it faster to lock someone out.
Check yourself
Which steps of the flow go through the back channel?
Alice opens a second app and is not asked for her password. What made that possible?
What arrives at your app's callback address in step 6?
7What each security measure stops
Why you need thisEvery parameter in §6 exists because of a real attack. If you know the attack, you know what breaks when a setting is wrong. A library will make these checks for you; whether the settings around them are right is up to you.
Each subsection names an attack, the defence, and, where the lab can show it, the real output of the lab refusing the attack. You can repeat every one of them in §13, task 7.
7.1A stolen code: PKCE
The attack. The code travels through the browser, so it can leak: into a log, a browser extension, or the history of a shared computer. Whoever gets it quickly might swap it for tokens.
The defence: PKCE. Before step 2, the app's server makes a random secret, the code verifier (43 to 128 characters). It sends Entra only its fingerprint, the code challenge: BASE64URL(SHA256(verifier)). In step 7 the server sends the verifier itself. Entra hashes it and compares the result with the challenge from step 2. Someone who stole the code has no verifier, and cannot compute one from the challenge (§3.1: hashes don't run backwards).
Figure 7.1 · Make a PKCE pairInteractive
crypto.getRandomValues, written as base64url (43 characters), and their SHA-256 fingerprint.In the lab
An "attacker" took a fresh code and even the real client secret, but not the verifier, and called the token endpoint:
{
"error": "invalid_grant",
"error_description": "AADSTS501481: The Code_Verifier does not match the code_challenge supplied in the authorization request."
}
The code was now used up, so when the real app arrived with it a moment later, it was refused too: AADSTS54005: OAuth2 Authorization code was already redeemed. A code works once.
PKCE was designed for apps that cannot keep a secret (mobile apps, browser apps), but Microsoft now recommends it for every kind of app, and requires it for single-page apps. Use it even though your web app also has a client secret.
7.2A code sent to the wrong place: exact redirect URIs
The attack. An attacker builds a sign-in link for your app but puts their own site in redirect_uri. If Entra followed it, the victim would sign in and Entra would deliver the code to the attacker.
The defence. You register every return address of your app in Entra in advance, and Entra compares the requested redirect_uri with that list exactly, character by character. If it doesn't match, Entra shows an error page and does not redirect anywhere. This is why you must register the precise production URL, with https and the exact path.
In the lab
The redirect URI 'https://attacker.example/steal' specified in the request does not match the redirect URIs configured for the application 'a0000000-0000-4000-8000-00000000000a'. The same error in Entra: AADSTS50011
The response was 400 with no Location header: the browser was not sent anywhere.
7.3A sign-in the user did not start: state
The attack, called login cross-site request forgery. The attacker signs in to your app with their own account, stops before the last step, and gets the victim's browser to open the callback URL with the attacker's code (through a link or a hidden image). The victim is now signed in as the attacker without noticing, and anything they upload or type ends up in the attacker's account.
The defence. In step 2 the app stores a random state value in a cookie in this browser and also sends it to Entra. In step 6 it checks that the state in the URL equals the one in the cookie. A callback URL made in the attacker's browser has the attacker's state, which does not match the victim's cookie.
In the lab
A callback URL produced in one browser was opened in another (a different cookie jar):
[App A] /auth/callback: state does not match this browser's login_state cookie -> refuse
7.4A token from another sign-in: nonce
The attack. Someone obtains an ID token from an earlier sign-in and tries to get your app to accept it again ("replay").
The defence. The app makes a random nonce for every sign-in, sends it in step 2, and keeps it. Entra copies it into the ID token. In step 9 the app checks that the token's nonce claim equals the one it kept. An old token has an old nonce.
7.5A forged or changed token: the signature
The attack. Edit a token's claims ("roles":["Admin"]), or write a token from scratch.
The defence. Check the signature with Entra's public key, chosen by the token's kid from the key set at jwks_uri (§3.4). Also accept only the algorithm you expect, RS256. Old libraries accepted "alg":"none" (no signature at all), or let an attacker switch to HS256 and "sign" with the public key; checking alg closes both holes. You did this attack yourself in Figure 4.1.
7.6A token made for another app or another company: aud and iss
The attack. Every app in your tenant receives genuine, correctly signed tokens from Entra. If your app accepted any of them, a token issued to another internal app could be presented to yours. With the multi-tenant common address, a token from another company's tenant would also be signed by Entra.
The defence. Check that aud equals your app's client ID, and that iss equals https://login.microsoftonline.com/your-tenant-id/v2.0. These two checks turn "Entra signed this" into "Entra signed this for my app in my company".
7.7An old token: exp and nbf
Reject a token after exp (expiry) and before nbf (not before). Servers' clocks are never exactly equal, so libraries allow a few minutes of difference, usually five.
7.8Someone pretending to be your app: the client credential
At the token endpoint, your app proves its identity with a client secret or a certificate (§8.4). Without it, anyone who knows your client ID (which is not secret; it is in every sign-in URL) could swap codes.
In the lab
{
"error": "invalid_client",
"error_description": "AADSTS7000215: Invalid client secret provided."
}7.9A stolen session: cookie settings
After sign-in, your session cookie is as valuable as the user's password for as long as the session lasts. Protect it with HttpOnly, Secure and SameSite=Lax (§2.2), HTTPS everywhere, and a limited lifetime. Don't put tokens in the browser's localStorage, where any script on the page can read them.
7.10A redirect after sign-in: open redirects
Apps often remember which page the user wanted ("return to /reports/42 after sign-in"). If the app accepts any address there, an attacker can send people to yourapp/login?next=https://evil.example: a real sign-in page of a trusted app that ends on the attacker's site. Accept only paths within your own app (starting with a single /).
7.11The ID token checklist
Your app, or its library, makes these checks in this order before it believes a single claim. The lab's validate_id_token function (§13) is these steps in about 30 lines.
- The token has three base64url parts that decode to JSON.
algisRS256, and thekidis in Entra's key set. If thekidis unknown, fetch the key set again once (rotation), then give up.- The signature over
header.payloadis valid for that key. issis exactly your tenant's issuer.audis exactly your client ID.- Now is before
expand afternbf, within about five minutes. nonceequals the value you stored for this sign-in.
Only then use the claims: oid and tid to identify the user, and roles to authorize (§9).
Figure 7.2 · Attack and defenceInteractive
Check yourself
Your app checks the signature but forgets to check aud. What can go wrong?
Someone copies a code from a URL in the browser history and calls the token endpoint with the app's client ID. What stops them?
Part III
Microsoft Entra ID
The objects you create in Entra, who may sign in and what they may do, and how sessions, token lifetimes and sign-out fit together.
8Tenants, app registrations and enterprise applications
Why you need thisBefore you write any code, your app has to be registered in Entra. If you know the objects and their exact names, you can ask your administrator for precisely what you need, and understand what they send back.
8.1The names, sorted out
Microsoft has renamed things several times, so the same idea appears under different names in older articles and libraries:
| Name | What it is |
|---|---|
| Microsoft Entra ID | The cloud identity service. Called Azure Active Directory (Azure AD) until 2023; libraries and old articles still say Azure AD or AAD. |
| Active Directory (AD DS) | The older on-premises directory on Windows servers. Many companies copy its users into Entra ID with Entra Connect. Your apps talk only to Entra ID. |
| Microsoft identity platform | The developer side of Entra: the sign-in endpoints, the libraries and the documentation. |
| MSAL | Microsoft Authentication Library, Microsoft's official libraries for getting tokens (Python, JavaScript, .NET, Java…). |
| Entra admin center | The website for managing Entra, entra.microsoft.com. The Azure portal shows the same settings. |
| Entra External ID | The variant for customer-facing apps, where customers create their own accounts. Not what internal apps use. |
8.2The tenant
A tenant is one organisation's directory in Entra: all its users, groups, registered apps and policies, kept apart from every other organisation. Each tenant has a tenant ID (shown in the admin center as Directory (tenant) ID), a GUID that never changes. It appears in every token as the tid claim, and in the addresses your app uses. A tenant also has domain names: one ending in .onmicrosoft.com and usually the company's own, such as company.com. If your company uses Microsoft 365, the tenant already exists, and your apps will be added to it.
Inside the tenant are users and groups. Most users are members (employees). Some are guests: people from other organisations who were invited, for example a contractor (Microsoft calls this B2B collaboration). Guests can sign in to your apps too, unless you prevent it, so decide early whether your internal apps should accept them.
8.3App registration and enterprise application: two objects for one app
Every app you connect to Entra is represented by two linked objects. The difference confuses almost everyone at first, and settings are split between them:
- The app registration (technically the application object) is the definition of your app: its client ID (Application (client) ID), its redirect URIs, its credentials, its app roles and which claims its tokens get. It is found under App registrations.
- The enterprise application (technically the service principal) is the app's presence in a particular tenant: which users and groups are assigned to it, whether assignment is required, which permissions have been consented, and its sign-in logs. It is found under Enterprise apps.
One way to think of it: the registration is the app's description; the enterprise application is that description in use in one company. For an internal, single-tenant app both live in your tenant, and creating the registration creates the enterprise application automatically. You still need to know which is which, because the settings are split:
| You want to… | Where |
|---|---|
| Find the client ID and tenant ID | App registrations › your app › Overview |
| Add or change redirect URIs and the front-channel logout URL | App registrations › your app › Authentication |
| Create a client secret or upload a certificate | App registrations › your app › Certificates & secrets |
| Define app roles (Admin, Reader) | App registrations › your app › App roles |
Add claims such as email or groups | App registrations › your app › Token configuration |
| See and grant permissions (scopes) | App registrations › your app › API permissions |
| Require assignment, so only chosen people can sign in | Enterprise apps › your app › Properties |
| Assign users and groups, and give them roles | Enterprise apps › your app › Users and groups |
| See who signed in, and why a sign-in failed | Enterprise apps › your app › Sign-in logs |
8.4Registering an app: every setting you will meet
These are the settings of an app registration, in the order you meet them, with what to choose for an internal web app:
- Name
- Shown to users on the consent page and in My Apps. Include the environment: expenses-prod, expenses-dev (§11.5).
- Supported account types
- Accounts in this organizational directory only (single tenant). The other options let other companies' users or personal Microsoft accounts sign in, which an internal app does not want.
- Platform
- Web for an app whose server does the sign-in and can keep a secret (everything in this chapter). Single-page application for JavaScript that gets tokens itself in the browser; it cannot keep a secret, must use PKCE, and its refresh tokens last 24 hours. Mobile and desktop for native apps.
- Redirect URIs
- Every exact return address, such as
https://expenses.company.com/auth/callback. Entra's rules:httpsonly, except forlocalhost; compared exactly, and paths are case-sensitive; at most 256 per app and 256 characters each; forlocalhostthe port is ignored when matching. Wildcards are technically possible through the manifest, but Microsoft strongly advises against them. - Front-channel logout URL
- Where Entra tells your app that the user signed out somewhere else (§10.2).
- Implicit grant and hybrid flows
- Two check boxes for ID tokens and access tokens from the authorize endpoint. Leave both off; the authorization code flow doesn't need them.
- Credentials (Certificates & secrets)
- How your server proves it is your app at the token endpoint. Three kinds:
- Client secret: a generated password. The portal limits it to 24 months; Microsoft recommends less than 12, and says secrets should not be used in production. The value is shown only once, when you create it.
- Certificate: you upload the public half; your server keeps the private key and signs a short JWT (a client assertion) at each token request. Microsoft's recommended choice.
- Federated credential: Entra trusts tokens from another identity system, such as a Kubernetes cluster or a CI pipeline, so there is no secret at all (workload identity federation).
- API permissions
- The scopes your app may ask for. A new registration gets
User.Readon Microsoft Graph; sign-in itself needs onlyopenid,profileand maybeemailandoffline_access. - Token configuration
- Extra claims (optional claims) such as
email,login_hintorfamily_name, and thegroupsclaim (§9.3). - App roles
- Roles your app understands, such as Admin and Reader, which appear in the
rolesclaim (§9.2). - Expose an API
- Only if other apps will call your app's API with access tokens: an Application ID URI (
api://…) and scopes of your own (§11.6). - Owners
- People who may manage this registration. Add your team, so that the app doesn't depend on one person's account.
- Manifest
- The whole registration as JSON. Some rare settings exist only here.
8.5The addresses for your tenant
Every address your app uses starts with the authority: https://login.microsoftonline.com/ followed by a tenant part. For an internal app, the tenant part is your tenant ID.
| What | Address |
|---|---|
Issuer (the value of iss) | https://login.microsoftonline.com/{tenant-id}/v2.0 |
| Discovery document | https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration |
| Authorize (step 2) | https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize |
| Token (step 7) | https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token |
| Public keys | https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys |
| Sign-out | https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/logout |
| UserInfo | https://graph.microsoft.com/oidc/userinfo |
Instead of a tenant ID you will see three special words in examples. organizations means any company's tenant, consumers means personal Microsoft accounts, and common means both. Apps for many companies use them. An internal app should not: with your tenant ID, Entra itself refuses users from other organisations, and the issuer is a single exact value you can check (§7.6). The v2.0 in the paths is the current version of the endpoints; older articles use paths without it, which give older-format tokens.
8.6Consent
Before Entra issues tokens with a scope for the first time, someone must consent to it (§5.1). For the basic sign-in scopes (openid, profile, email, User.Read), users can usually consent for themselves on first sign-in. Many companies turn user consent off; then an administrator presses Grant admin consent once, for everyone. When the enterprise application has Assignment required turned on, Microsoft's documentation says user consent is not allowed at all, so admin consent is needed. A missing consent shows up as AADSTS65001.
8.7Who in your company can do what
Some of these steps need rights you may not have. Knowing the role names saves several rounds of email:
| Task | Who can do it |
|---|---|
| Create an app registration | By default, any user. Many companies switch this off (Users can register applications: No); then you need the Application Developer role, or an admin creates it and makes you an owner. |
| Change a registration (redirect URIs, secrets, roles) | Its owners, or a Cloud Application Administrator or Application Administrator. |
| Require assignment, assign users and groups | Owner of the enterprise application, Cloud Application Administrator, Application Administrator, or User Administrator. |
| Grant admin consent | Cloud Application Administrator or Application Administrator for most permissions; some Microsoft Graph permissions need a higher role. |
| Conditional Access policies (MFA, devices) | Security staff (Conditional Access Administrator, Security Administrator). The app administrator roles cannot change them. |
Two features depend on licences. Assigning groups to an app (rather than individual users) and Conditional Access both need Microsoft Entra ID P1, which is included in Microsoft 365 E3, E5 and Business Premium. Sign-in logs and the sign-in itself work with the free tier. Ask which licence your company has before you plan access around groups.
Check yourself
You need only the people in the "Finance" group to be able to sign in to the expense tracker. Which object do you change?
Which address should an internal, single-tenant app use as its authority?
9Who may enter, and what they may do
Why you need thisA sign-in only proves who someone is (§1.3). Your apps must also decide who may use them at all, and what each person may do inside them. Entra can do the first; you build the second with its help.
9.1Layer one: who can sign in at all
By default, every user in your tenant who can sign in can get a token for your app. That is fine for a company-wide wiki and wrong for a finance tool. To restrict it, open the enterprise application, set Properties › Assignment required? to Yes, and assign the users or groups who may use it under Users and groups.
Unassigned users are stopped by Entra itself, with error AADSTS50105, before your app sees them at all. Three details from Microsoft's documentation:
- Assigning groups needs Entra ID P1 (§8.7).
- Only direct members of an assigned group get in; members of a group inside that group do not (nested groups are not supported).
- The assignment requirement does not apply to Global Administrators, so test with an ordinary account.
In the lab
App B requires assignment and only Alice is assigned. Bob signed in correctly and was then stopped:
Sign-in stopped The signed in user 'bob@lab.test' is not assigned to the application App B. Your administrator set this application to block users who were not assigned to it. The same error in Entra: AADSTS50105
9.2Layer two: roles inside your app
Inside the app, different people may do different things. Entra's tool for this is the app role:
- In the app registration, under App roles, create roles such as Admin and Reader. Each has a display name, a value (the text that appears in tokens, such as
Admin), and who it is for (users and groups, applications, or both). - In the enterprise application, under Users and groups, assign people or groups and pick a role for each assignment.
- Entra puts the values of the user's roles into the ID token's
rolesclaim:"roles": ["Admin"]. A user with no role gets norolesclaim. - Your code reads the claim after checking the token and answers
403when the role is missing.
In FastAPI, the check in step 4 is a few lines. This sketch assumes the signed-in user's claims were saved in the session after sign-in, as in the example in §14.3:
from fastapi import Depends, HTTPException, Request
def require_role(role: str):
def check(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(401, "Not signed in")
if role not in user.get("roles", []):
raise HTTPException(403, f"This page needs the {role} role")
return user
return check
@app.get("/admin")
def admin_page(user = Depends(require_role("Admin"))):
return {"hello": user["name"]}
In the lab
Alice is assigned the Admin role in App A; Bob is not. Bob can use App A, but its admin page refuses him:
[App A] /admin: Bob Example has roles [] -> 403
9.3The groups claim, and its limit
Instead of app roles, Entra can put the user's groups into tokens (Token configuration › Add groups claim). The groups claim contains the groups' object IDs (GUIDs), not their names, so your code needs a table from GUID to meaning. There is also a size limit: when a user is in more than 200 groups (150 for SAML), Entra leaves the claim out and adds a pointer instead (_claim_names and _claim_sources), and the app must ask Microsoft Graph for the list, which needs a further permission.
App roles avoid both problems: the values are names you choose, tokens contain only the roles of this app, and you can still assign a role to a whole group. Prefer app roles; use the groups claim only when the groups themselves are the point.
9.4MFA and Conditional Access come free
Your company's security team sets Conditional Access policies in Entra: "require multi-factor authentication (MFA)", "only from company-managed devices", "block sign-ins from these countries", "sign in again every 12 hours". Entra applies them in step 4 of the flow, on its own pages, for every app. Your app gets all of it without writing a line of code, and should never build its own MFA. If a policy blocks a user, the error appears on Entra's page (for example AADSTS50076, "you must use multi-factor authentication"), and the sign-in logs say which policy did it. Access tokens carry an amr claim that lists the methods used, for example ["pwd","mfa"].
9.5Identifying a user correctly
Your database will store things per user: preferences, history, ownership. The key you store them under must never change and never be reused:
- Use
oid(object ID), together withtid. It is the user's permanent ID in your tenant, the same in all your apps, and it is never reused. subis also permanent, but it is different in every app (Entra calls this pairwise). Use it only if you specifically want apps not to be able to match users.- Never use
email,preferred_usernameornameto identify a user or to decide access. They change when people change names, and an email address can be given to a new employee after the old one leaves. Microsoft's documentation says the same.
In the lab
The same person signed in to both apps. oid is identical; sub is not:
App A sub: ZZ0Rz5aHSwXT1C3s7yKz5mx2c4GB1ICHZsRkI97SJ_A oid: aaaaaaaa-1111-4111-8111-111111111111 App B sub: nLRXgkudIq0UPuA1xZllEvSKJUAXpva6ilO01aBwn0g oid: aaaaaaaa-1111-4111-8111-111111111111
Figure 9.1 · Every claim you will meetInteractive
Check yourself
Which claim should be the key of your users table?
The expense tracker has Admin and Reader users. What is the usual Entra way to tell the app who is which?
10Sessions, lifetimes and signing out
Why you need thisMost surprises in production ("why can this person still get in?", "why are people signed out at lunchtime?") come from mixing up the different sessions and token lifetimes. There are three clocks, and they run independently.
10.1Three clocks
- Entra's session, kept by Entra's cookie at
login.microsoftonline.com. It is what makes single sign-on work. According to Microsoft's documentation, a persistent session lasts until 90 days without use and a non-persistent one until 24 hours without use, each extended when used. Your company can shorten this with a Conditional Access sign-in frequency policy. - Your app's session, kept by your app's cookie. You choose its length.
- The tokens: the ID token (1 hour), access tokens (60–90 minutes), refresh tokens (until 90 days without use). Your app checks the ID token once at sign-in; after that, what counts is its own session.
Figure 10.1 · What happens when an account is disabledInteractive
10.2Signing out
Signing out properly takes two steps, and Microsoft's documentation asks for both:
- End your app's session: delete the session record and clear the cookie.
- Send the browser to Entra's sign-out address (
end_session_endpointin the discovery document) withpost_logout_redirect_uri, the page to return to afterwards. That address must be one of the app's registered redirect URIs.
If you skip step 2, Entra's session survives, and the next click on "Sign in" signs the user straight back in without a password. Users experience that as "sign-out doesn't work".
What about the other apps the user signed in to? That is single sign-out, and Entra does it with front-channel logout. You register a front-channel logout URL for each app. When the user signs out anywhere, Entra's sign-out page makes the browser request that URL for every app the user signed in to in that Entra session. Each app clears its session and answers 200.
Two limits to know about. It works only for apps used in that browser session; and browsers load those URLs in hidden frames, where they increasingly refuse to send your app's cookies (third-party cookie blocking). The lab's apps therefore find the session to end by the sid value Entra passes in the URL instead of by cookie. In OpenID Connect, sid is the identity provider's session ID, which also appears as a claim in the ID token.
In the lab
Alice signed out in App B. Mini Entra ended its session and the browser loaded both apps' logout URLs. App A's log:
[mini-entra] logout: ended the IdP session of alice@lab.test; telling 2 app(s) through their front-channel logout URLs [App A] front-channel logout from the IdP: ended 1 session(s)
10.3When someone is disabled or leaves
Back to Alice leaving the company (Figure 1.1). When her account is disabled in Entra:
- New sign-ins stop at once, in every app.
- App sessions that are already open keep working until they end, because the app does not ask Entra on every request. With an 8-hour app session, that can be most of a day.
- Refresh tokens can no longer get new tokens (Entra answers
AADSTS50057, user account is disabled). An administrator's Revoke sessions in Entra also cancels the user's refresh tokens and Entra session. Neither reaches into your app's own session.
So keep app sessions short (one hour is a good default; it costs users nothing, because renewing goes through Entra's session without a password), or re-check with Entra regularly. Libraries can do this silently with prompt=none, which fails if the user may no longer sign in. Some Microsoft services also support Continuous Access Evaluation, which pushes such events to resources within minutes; your own apps would need extra work to use it.
Check yourself
Your app deletes its session cookie on "Sign out", but users are signed straight back in when they press "Sign in". Why?
An account is disabled at 11:00. The app uses 8-hour sessions, and the user signed in at 09:00. Until when can they keep using the app?
Part IV
Building single sign-on for your internal apps
Where the sign-in code lives, what changes in containers on AWS, a lab that runs the whole thing on your Mac, and a plan for the real project.
11Three ways to add Entra sign-in to your apps
Why you need thisYou will have several internal apps. You can put the sign-in into each app, or in front of all of them. The choice decides how much code each team writes, where secrets live, and how fine-grained access can be.
11.1Pattern A: each app signs users in itself
Each app includes an OpenID Connect library and runs the whole flow itself, as the lab's apps do. For Python:
| Library | From | Good for |
|---|---|---|
Authlib (authlib) | Open source, works with any OIDC provider | FastAPI and Starlette web apps: sign-in in a few lines (§14.3) |
MSAL Python (msal) | Microsoft | Getting tokens from Entra in any Python program, including calling Microsoft Graph or your own APIs as the user |
identity (identity) | Microsoft, built on MSAL | Flask, Django and Quart web apps |
| fastapi-azure-auth, PyJWT | Open source | APIs that receive access tokens in the Authorization header and must check them |
Choose this when an app needs its own roles and pages per user, calls other APIs on the user's behalf, or must run outside your usual infrastructure. The cost: every app needs the code, the configuration, a credential and a session store.
11.2Pattern B: a sign-in proxy in front of the apps
Here the apps contain no sign-in code at all. A separate container, oauth2-proxy, runs the OpenID Connect flow with Entra and keeps its own session cookie. Your reverse proxy (Caddy or nginx, the extra container you learned about for HTTPS) asks oauth2-proxy about every request before passing it on. Unsigned requests are sent to sign in; signed-in requests reach the app with headers such as X-Auth-Request-Email and X-Auth-Request-User.
oauth2-proxy has a provider made for Entra (provider = "entra-id"), can allow only certain groups, and fetches a user's groups from Microsoft Graph when they are in more than 200 (§9.3). The configuration is in §14.4.
The rule that makes it safe
The app believes whatever the identity headers say. So the app must be reachable only through the proxy: no published port of its own, only a private container network to the proxy, and the proxy must remove any identity headers that arrive from the outside. Otherwise anyone can send X-Auth-Request-Email: ceo@company.com straight to the app.
Choose this when you have many internal tools where "everyone in group X may use it" is enough: dashboards, model playgrounds, admin consoles, or software you can't change. The cost: one more container, coarse access control unless the app reads the headers itself, and apps can't easily call other APIs as the user.
11.3Pattern C: let the AWS load balancer do it
If your apps run behind an AWS Application Load Balancer (ALB), its HTTPS listener can sign users in itself. A listener rule with the action authenticate-oidc gets Entra's addresses (issuer, authorization, token and user-info endpoints) and a client ID and secret. The ALB then runs the flow, keeps a session cookie (named AWSELB…), and forwards signed-in requests to your containers with three extra headers.
- The redirect URI you register in Entra is
https://your-alb-or-domain/oauth2/idpresponse. - It requires an HTTPS listener, and the ALB must be able to reach Entra's token endpoint and Microsoft Graph's user-info endpoint on the internet.
- The session lasts 7 days by default (
SessionTimeout); set something much shorter. - Your app receives
x-amzn-oidc-data, a JWT signed by the load balancer with ES256. AWS's documentation says to check its signature with the key fromhttps://public-keys.auth.elb.region.amazonaws.com/kid, and that itssignerfield is your ALB's ARN, before trusting it. The other two headers (x-amzn-oidc-identity,x-amzn-oidc-accesstoken) are not signed. - As in pattern B, the containers must accept traffic only from the load balancer (a security group rule).
Choose this when the apps already sit behind an ALB and you want no sign-in container. The cost: it ties sign-in to AWS, sign-out needs care (the app must expire the ALB's cookies and send the user to Entra's sign-out), and you still check a signed header in each app.
11.4Choosing
| A: library in each app | B: oauth2-proxy | C: AWS ALB | |
|---|---|---|---|
| Sign-in code in the app | Yes, a few dozen lines | None | Only the header check |
| Roles inside the app | Full (roles claim) | Groups, or the app reads headers | From the claims header |
| Call APIs as the user | Yes | Limited | Limited |
| Extra containers | None | oauth2-proxy (and a reverse proxy) | None (needs an ALB) |
| Works off AWS, and locally | Yes | Yes | No |
| Secret lives in | Each app | The proxy | The ALB |
Figure 11.3 · Which pattern fits one app?Interactive
11.5One registration per app, per environment
Give every app its own app registration, and give each environment (development, staging, production) its own registration too: expenses-dev, expenses-prod. Then:
- each app's tokens have their own
aud, so a token for one app is useless at another (§7.6); - a leaked secret affects one app in one environment;
- development redirect URIs such as
http://localhost:8000/…never appear on the production registration, which Microsoft recommends; - assignment, roles and sign-in logs are per app.
The exception is pattern B: one oauth2-proxy (and one registration) can protect several apps under the same domain.
11.6When your app is also an API
Three situations go beyond "a user signs in to a web app". Each has its own flow; you only need to recognise them for now.
- A JavaScript front end with a Python API. Two designs. In the first, the browser code (with MSAL.js, registered as a single-page application) gets an access token for your API and sends it in the
Authorization: Bearerheader; the API checks it like an ID token, but withaudequal to the API's own client ID and with its scopes or roles. In the second, called backend for frontend, your server does the sign-in exactly as in §6 and the browser only ever holds a session cookie. For internal apps the second is simpler and keeps tokens out of the browser. - One service calls another on the user's behalf. The first API swaps the user's access token for one to the second API: the on-behalf-of flow.
- A background job with no user. The client credentials flow: the job proves it is itself with its own credential and gets a token whose
rolesclaim holds application roles (roles whose allowed member type is Applications).
Going deeperChecking access tokens in your own API
To let other apps call your API, open its app registration, set an Application ID URI under Expose an API (Entra suggests api://client-id), and add a scope such as access_as_user. Clients then ask for api://client-id/access_as_user.
Your API checks each incoming access token with the checklist in §7.11, with three changes: aud must be the API's client ID; there is no nonce; and you check that scp (for user tokens) or roles (for application tokens) contains what the endpoint requires.
One classic surprise: unless the API's manifest asks for version 2 tokens (requestedAccessTokenVersion: 2), Entra issues version 1 access tokens for it, whose issuer is https://sts.windows.net/tenant-id/ rather than the v2.0 address, and the issuer check fails.
12Running it in containers on AWS
Why you need thisThe protocol is the same everywhere, but containers, proxies and several copies of an app add their own ways to break it. Each of these has caught real teams.
12.1HTTPS and the redirect URI behind a proxy
Users open https://expenses.company.com. The HTTPS connection ends at the reverse proxy or the load balancer, which passes the request on to your container as plain HTTP, to something like http://app:8000. If your app builds its redirect URI from the request it sees, it builds http://app:8000/auth/callback, which does not match the registered https://expenses.company.com/auth/callback. Entra answers AADSTS50011.
Two fixes. Set the redirect URI from configuration (REDIRECT_URI=https://expenses.company.com/auth/callback), as the examples in this chapter do. Or let the app trust the proxy's X-Forwarded-Proto and X-Forwarded-Host headers: for uvicorn that is --proxy-headers --forwarded-allow-ips=the proxy's address. The same mismatch makes Secure cookies misbehave, so fix it once for both.
12.2Secrets
- The client secret goes nowhere near the image, Git or
compose.yaml. At run time it comes from AWS Secrets Manager (read with the EC2 instance's IAM role) or from a Composesecrets:file, which appears inside the container at/run/secrets/nameand does not show inpodman inspect. The lab uses the second way. - A client secret expires, after 24 months at most. Put the date in the team calendar. To rotate: add a second secret, deploy it, check sign-ins, then delete the old one. Both work during the overlap.
- Better than secrets: a certificate, or a federated credential with no secret at all (§8.4).
- The key that signs your session cookies is a secret too, and must be the same on every copy of the app.
12.3More than one copy of the app
Behind a load balancer, the request to /login (step 2) and the request to /auth/callback (step 6) can reach different copies of your app. If the first copy kept the state, nonce and PKCE verifier in its memory, the second copy has never heard of them, and the sign-in fails at random with "state does not match". Sessions kept in one copy's memory disappear the same way.
The lab's apps keep everything in memory, so they would fail like this. Real apps keep the in-progress sign-in values in a signed cookie (Authlib does this through the session middleware), and keep sessions either in signed cookies or in a shared store such as Redis, with the same signing key on all copies. "Sticky sessions" on the load balancer also work, but break when a copy is replaced.
12.4Health checks and other public paths
The load balancer's health check cannot sign in. Keep /health (and readiness endpoints) outside the sign-in requirement, and make sure they reveal nothing. The callback path and the sign-out landing page must also be reachable without a session, or the sign-in can never finish.
12.5Outbound network and clocks
Steps 7 and 8 are your server calling login.microsoftonline.com on port 443, and reading Entra's keys. If the EC2 instance's security group or network blocks outgoing HTTPS, sign-in hangs at the callback. Tokens also carry times: if a server's clock is wrong by more than the allowed skew (about five minutes), every token looks expired or not yet valid. EC2 instances keep time with Amazon's time service by default, and containers use the host's clock.
12.6Logs
Never log tokens, codes, secrets or session cookies; each one is a working credential. The lab's logs print only the first six characters of a code. Do log each sign-in's result, the user's oid, and Entra's error codes together with the Trace ID and Correlation ID from its error responses: those two IDs are what an Entra administrator needs to find the failed sign-in in Entra's sign-in logs.
12.7Local development
For development, use a separate registration with a redirect URI such as http://localhost:8000/auth/callback. Plain HTTP is allowed for localhost, and for localhost Entra ignores the port when matching. Or develop against the lab's Mini Entra (§13), which needs no tenant and no network.
13Lab: watch single sign-on happen on your Mac
Why you need thisEverything in Parts I–III happens inside a few redirects that take less than a second. In this lab you slow it down: three containers on your Mac, with a log line for every step, and every attack from §7 tried for real.
13.1What is in the lab
The lab runs three containers from the python:3.12-slim image, using only Python's standard library:
- Mini Entra, at
http://idp.localhost:9000: a small OpenID Connect identity provider that plays Entra's part. It has a "tenant" with two test users and two app registrations, a sign-in page, single-use codes, RS256-signed ID tokens, a discovery document, a key set, sign-out and front-channel logout. Its URL paths copy Entra's, and for the mistakes it detects it reports the same error codes (AADSTS…) that Entra uses, so you will recognise them later. - App A at
http://app-a.localhost:8001and App B athttp://app-b.localhost:8002: the same small web app, started twice with different settings. It does everything a real app does in §6, and checks tokens with the list in §7.11.
The files are in the sso-lab folder of this chapter's repository; they are also listed at the end of this section. Names ending in .localhost always mean your own computer, and each name has its own cookies in the browser, just as login.microsoftonline.com and expenses.company.com do.
| Test user | Password | App A | App B (assignment required) |
|---|---|---|---|
alice@lab.test | lab-password | Signed in, role Admin | Assigned, no role |
bob@lab.test | lab-password | Signed in, no role | Not assigned: refused |
You need: Podman with Compose, the python:3.12-slim image (if you don't have it yet, it is a 149 MB download), Git, and Chrome or Firefox. The lab was tested with curl and in a Chromium-based browser; Safari may not resolve names such as app-a.localhost.
13.2Tasks
Task 1 · Start the lab
Get the files, then start the three containers:
git clone https://github.com/Espad0/sso-with-entra-id.git ~/sso-with-entra-id cd ~/sso-with-entra-id/sso-lab podman compose up -d
Open a second terminal and leave this running there for the rest of the lab. Every sign-in step prints a line:
cd ~/sso-with-entra-id/sso-lab podman compose logs -f
idp-1 | [mini-entra] made a new RSA signing key, kid=scdKoWJjUq1fkDDRJ_gq8befig5 idp-1 | [mini-entra] issuer http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/v2.0 app-a-1 | [App A] listening on http://app-a.localhost:8001; trusts issuer http://idp.localhost:9000/0f1e…/v2.0 app-b-1 | [App B] listening on http://app-b.localhost:8002; trusts issuer http://idp.localhost:9000/0f1e…/v2.0
Your kid will differ: Mini Entra makes a new key pair every time it starts.
Task 2 · Read the discovery document
curl -s http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/v2.0/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'{
"issuer": "http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/v2.0",
"authorization_endpoint": "http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/oauth2/v2.0/authorize",
"token_endpoint": "http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/oauth2/v2.0/token",
"jwks_uri": "http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/discovery/v2.0/keys"
}
Run the same command against Entra (§5.5) and compare the field names.
Task 3 · Sign in to App A
Open http://app-a.localhost:8001, select Sign in with Mini Entra, and sign in as Alice (the form is filled in). App A shows Hello, Alice Example, the claims of its ID token, and the token itself. Meanwhile the log printed these lines; the numbers are the steps of Figure 6.1:
| Step | Log line |
|---|---|
| 2 | [App A] /login: new sign-in (state Cs2rIW..., nonce iPQ6Xy...) -> send the browser to the IdP |
| 2 | [App A] read the discovery document; keys are at http://idp.localhost:9000/…/discovery/v2.0/keys |
| 3 | [mini-entra] /authorize from App A: no IdP session for this browser -> show the sign-in page |
| 4 | [mini-entra] alice@lab.test signed in with a password -> IdP session cookie set for idp.localhost |
| 5 | [mini-entra] code BJJ2IH... issued to App A for alice@lab.test (single use, valid 60 s) -> send the browser back to the app |
| 6 | [App A] /auth/callback: state matches this browser's cookie -> OK |
| 8 | [mini-entra] /token: App A swapped the code for tokens: secret OK, PKCE OK -> ID token for alice@lab.test signed with key scdKoWJjUq1fkDDRJ_gq8befig5 |
| 8 | [App A] back channel: swapped the code for an ID token at the token endpoint |
| 9 | [App A] fetched 1 public signing key(s) from jwks_uri |
| 9 | [App A] ID token checks: signature OK, iss OK, aud OK, exp OK, nonce OK |
| 10 | [App A] signed in Alice Example (oid aaaaaaaa-1111-4111-8111-111111111111) -> app session cookie set |
On the test run, all of it took less than a tenth of a second.
Task 4 · Single sign-on into App B
On App A's page, select Open the other app, then Sign in with Mini Entra. There is no password page. The log shows why:
[mini-entra] /authorize from App B: this browser already has an IdP session for alice@lab.test -> no sign-in page. This is single sign-on.
Compare the claims on the two pages: oid is the same, sub differs, aud is each app's own client ID, and only App A's token has roles (§9.5).
Task 5 · See the redirect without a browser
curl -si http://app-a.localhost:8001/login | head -6
You get the 302, the Location pointing to Mini Entra, and the login_state cookie from §2.1. Paste the Location URL into Figure 2.1 to decode it.
Task 6 · Authorization: Bob
Sign out of App A (this also signs you out of App B; see task 10), then sign in to App A as bob@lab.test. App A lets him in, but its Admin page answers 403. Then open App B: Mini Entra stops him with AADSTS50105, because App B requires assignment and only Alice is assigned (§9.1).
Task 7 · Break it on purpose
These commands play an attacker with curl. The first block signs in as Alice the way a browser would, but stops before the browser returns to App A, and prints the callback URL with the code:
T=http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211
rm -f jar.txt
AUTH=$(curl -s -o /dev/null -c jar.txt -b jar.txt -w '%{redirect_url}' http://app-a.localhost:8001/login)
PAGE=$(curl -s -c jar.txt -b jar.txt "$AUTH" | grep -o 'name="page_id" value="[^"]*"' | cut -d'"' -f4)
BACK=$(curl -s -o /dev/null -c jar.txt -b jar.txt -w '%{redirect_url}' \
-d page_id=$PAGE -d username=alice@lab.test -d password=lab-password $T/login)
echo "$BACK"http://app-a.localhost:8001/auth/callback?code=kXHgQKZkuM5vYiqjUHpocJNV7M5BoLuYp5KizUSimqc&state=t6usfSsXYlAnb9X5Wi0NEg
A stolen code. The attacker has the code, and even App A's client secret, but not the PKCE verifier, which exists only in App A's memory (§7.1):
CODE=$(echo "$BACK" | sed 's/.*code=\([^&]*\).*/\1/') curl -s $T/oauth2/v2.0/token -d grant_type=authorization_code -d code=$CODE \ -d client_id=a0000000-0000-4000-8000-00000000000a -d client_secret=lab-secret-for-app-a \ -d redirect_uri=http://app-a.localhost:8001/auth/callback; echo
{
"error": "invalid_grant",
"error_description": "AADSTS501481: The Code_Verifier does not match the code_challenge supplied in the authorization request."
}
A code works once. Now let the real App A use the same code:
curl -s -c jar.txt -b jar.txt "$BACK" | grep -o 'AADSTS[0-9]*: [^<]*'
AADSTS54005: OAuth2 Authorization code was already redeemed.
A redirect URI that is not registered (§7.2). Mini Entra refuses, and sends the browser nowhere (400, empty redirect):
curl -s -o /dev/null -w '%{http_code} [%{redirect_url}]\n' "$T/oauth2/v2.0/authorize?client_id=a0000000-0000-4000-8000-00000000000a&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fattacker.example%2Fsteal"400 []
A wrong client secret (§7.8):
curl -s $T/oauth2/v2.0/token -d grant_type=authorization_code -d code=anything \ -d client_id=a0000000-0000-4000-8000-00000000000a -d client_secret=a-guess; echo rm -f jar.txt
{
"error": "invalid_client",
"error_description": "AADSTS7000215: Invalid client secret provided."
}
A sign-in your browser did not start (§7.3). Open the callback URL that the first block printed in your browser. That sign-in was started by curl, so your browser has no matching login_state cookie: App A answers Sign-in refused and logs state does not match this browser's login_state cookie.
Task 8 · Check a token yourself
Copy the ID token from App A's page (the coloured text at the bottom), and the key set from this command:
curl -s http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/discovery/v2.0/keys
Paste both into the workbench in Figure 4.1, set the expected nonce to the nonce claim shown on App A's page, untick the time box, and run the checks: all pass. Change one letter of the payload and they fail at the signature.
Going deeperThe same signature check with OpenSSL
The signature was also checked with OpenSSL 3.6.3, independently of the lab's own code, after converting the key set's n and e into a PEM public key. It answered Verified OK, and after appending one byte to the signed text, Verification failure. So the lab's tokens are ordinary RS256 JWTs that any library can check.
Task 9 · Key rotation, and an identity provider that restarts
podman compose restart idp
Mini Entra starts with a new key pair and an empty memory. Reload App A: you are still signed in, because App A's session is App A's own (§6.5); an Entra outage does not sign out people who already have an app session. Now open App B and sign in: Mini Entra has forgotten you, so it shows the sign-in page. After you sign in, App B's log shows fetched 1 public signing key(s) from jwks_uri: it saw an unknown kid and fetched the new key set by itself (§3.4).
Task 10 · Sign out everywhere
In App B, select Sign out. Mini Entra's page says it ended its session and told two apps; one and a half seconds later you are back at App B, signed out. Reload App A: signed out there too. The log:
[App B] /logout: app session ended -> send the browser to the IdP to end its session too [mini-entra] logout: ended the IdP session of alice@lab.test; telling 2 app(s) through their front-channel logout URLs [App A] front-channel logout from the IdP: ended 1 session(s)
Clean up
podman compose down
This removes the three containers and their network. The python:3.12-slim image stays.
13.3Exercises
Try these on your own; the lab folder contains no solutions.
- A new role. Give Bob a
Reviewerrole in App A by editingidp/directory.json, restartidp, and sign in as Bob. Where does the role appear? Then make App A's admin page acceptRevieweras well. - Force the password page. Open
http://app-b.localhost:8002/login?prompt=loginwhile signed in. What does Mini Entra's log say, and why would a real app do this before a sensitive action? - A short session. In
app/app.py, makecurrent_sessionend sessions after two minutes instead of at the token'sexp. Restart App A, wait, reload, and watch the log: does the user see a password page? Why not? - Expired codes. Set
CODE_LIFETIMEinidp/idp.pyto 5 seconds, repeat the first block of task 7, wait ten seconds, then let App A use the code. Which error appears, and what is Entra's code for it (§14.6)? - Two copies. Explain, without running it, what would happen if App A ran as two containers behind a round-robin load balancer, and which two dictionaries in
app.pywould cause it (§12.3).
13.4The lab's files
All five files, as they are in the repository's sso-lab folder. Read app/app.py first: login, callback and validate_id_token are §6 and §7.11 in code.
Filecompose.yaml
# SSO lab: one identity provider (a stand-in for Entra ID) and two apps.
# The browser reaches them at idp.localhost:9000, app-a.localhost:8001 and
# app-b.localhost:8002. Ports are bound to 127.0.0.1, so only this Mac can reach them.
services:
idp:
image: docker.io/library/python:3.12-slim
command: ["python", "-u", "/srv/idp.py"]
volumes:
- ./idp:/srv:ro
ports:
- "127.0.0.1:9000:9000"
networks:
default:
aliases:
- idp.localhost # the apps reach the IdP at the same URL as the browser
app-a:
image: docker.io/library/python:3.12-slim
command: ["python", "-u", "/srv/app.py"]
volumes:
- ./app:/srv:ro
ports:
- "127.0.0.1:8001:8001"
environment:
APP_NAME: App A
APP_COLOR: "#0b6b6b"
PORT: "8001"
PUBLIC_URL: http://app-a.localhost:8001
OTHER_APP_URL: http://app-b.localhost:8002
ISSUER: http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/v2.0
CLIENT_ID: a0000000-0000-4000-8000-00000000000a
CLIENT_SECRET_FILE: /run/secrets/app_a_secret
secrets:
- app_a_secret
depends_on:
- idp
app-b:
image: docker.io/library/python:3.12-slim
command: ["python", "-u", "/srv/app.py"]
volumes:
- ./app:/srv:ro
ports:
- "127.0.0.1:8002:8002"
environment:
APP_NAME: App B
APP_COLOR: "#6a3fa0"
PORT: "8002"
PUBLIC_URL: http://app-b.localhost:8002
OTHER_APP_URL: http://app-a.localhost:8001
ISSUER: http://idp.localhost:9000/0f1e2d3c-4b5a-4697-8877-665544332211/v2.0
CLIENT_ID: b0000000-0000-4000-8000-00000000000b
CLIENT_SECRET_FILE: /run/secrets/app_b_secret
secrets:
- app_b_secret
depends_on:
- idp
secrets:
app_a_secret:
file: ./secrets/app-a-secret.txt
app_b_secret:
file: ./secrets/app-b-secret.txtFileapp/app.py: the web app (runs as App A and App B)
"""A small web app that signs users in with OpenID Connect.
The same file runs as App A and App B (see compose.yaml). It talks to mini-entra
here, and it talks to Microsoft Entra ID the same way: it learns every URL from
the discovery document and checks tokens with the keys the IdP publishes.
To point it at Entra you change ISSUER, CLIENT_ID and the secret.
Teaching code: in a real project use a maintained library (MSAL, Authlib),
HTTPS everywhere, Secure cookies, and a shared session store.
"""
import base64
import hashlib
import hmac
import html
import json
import os
import secrets
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlsplit
APP_NAME = os.environ["APP_NAME"]
PORT = int(os.environ["PORT"])
PUBLIC_URL = os.environ["PUBLIC_URL"] # where the browser reaches this app
OTHER_APP_URL = os.getenv("OTHER_APP_URL", "")
ISSUER = os.environ["ISSUER"] # who we trust to sign users in
CLIENT_ID = os.environ["CLIENT_ID"] # this app's ID in the IdP
with open(os.environ["CLIENT_SECRET_FILE"]) as f:
CLIENT_SECRET = f.read().strip() # proves to the IdP that we are this app
COLOR = os.getenv("APP_COLOR", "#0b6b6b")
REDIRECT_URI = PUBLIC_URL + "/auth/callback"
SCOPES = "openid profile"
CLOCK_SKEW = 300 # seconds of clock difference we accept
def log(msg):
print(f"[{APP_NAME}] {msg}", flush=True)
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
# ---------------------------------------------------------------------------
# 1. Discovery and keys: everything the app needs to know about the IdP
# ---------------------------------------------------------------------------
_metadata, _keys = None, {}
def fetch_json(url, data=None):
body = urlencode(data).encode() if data is not None else None
request = urllib.request.Request(url, data=body)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
except urllib.error.HTTPError as e: # the IdP explains errors in a JSON body
return json.load(e)
def metadata():
global _metadata
if _metadata is None:
doc = fetch_json(ISSUER + "/.well-known/openid-configuration")
if doc.get("issuer") != ISSUER:
raise RuntimeError(f"discovery says issuer {doc.get('issuer')}, expected {ISSUER}")
_metadata = doc
log(f"read the discovery document; keys are at {doc['jwks_uri']}")
return _metadata
def signing_key(kid):
if kid not in _keys: # a key we have not seen: the IdP may have rotated keys, so re-fetch
_keys.clear()
for k in fetch_json(metadata()["jwks_uri"])["keys"]:
if k.get("kty") == "RSA" and k.get("use", "sig") == "sig":
_keys[k["kid"]] = (int.from_bytes(b64url_decode(k["n"]), "big"),
int.from_bytes(b64url_decode(k["e"]), "big"))
log(f"fetched {len(_keys)} public signing key(s) from jwks_uri")
return _keys.get(kid)
# ---------------------------------------------------------------------------
# 2. Checking an ID token. Each check below stops one kind of attack.
# ---------------------------------------------------------------------------
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
def rs256_verify(data: bytes, signature: bytes, n: int, e: int) -> bool:
k = (n.bit_length() + 7) // 8
s = int.from_bytes(signature, "big")
if len(signature) != k or s >= n:
return False
t = SHA256_DIGEST_INFO + hashlib.sha256(data).digest()
expected = b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
return hmac.compare_digest(pow(s, e, n).to_bytes(k, "big"), expected)
def validate_id_token(token: str, expected_nonce: str) -> dict:
"""Return the claims if every check passes; raise ValueError naming the failed check."""
try:
header_b64, payload_b64, signature_b64 = token.split(".")
header = json.loads(b64url_decode(header_b64))
claims = json.loads(b64url_decode(payload_b64))
except ValueError:
raise ValueError("not a JWT (expected three base64url parts)")
# 1. Signature: was it made with the IdP's private key, and is it unchanged?
if header.get("alg") != "RS256":
raise ValueError(f"alg is {header.get('alg')!r}; only RS256 is accepted")
key = signing_key(header.get("kid"))
if key is None:
raise ValueError("signed with a key the IdP does not publish")
if not rs256_verify(f"{header_b64}.{payload_b64}".encode(), b64url_decode(signature_b64), *key):
raise ValueError("signature check failed: the token was changed or not made by the IdP")
# 2. Issuer: made by the IdP (and tenant) we trust?
if claims.get("iss") != ISSUER:
raise ValueError(f"iss is {claims.get('iss')}; expected {ISSUER}")
# 3. Audience: made for this app, not for another one?
if claims.get("aud") != CLIENT_ID:
raise ValueError(f"aud is {claims.get('aud')}; this app is {CLIENT_ID}")
# 4. Time: still valid?
now = time.time()
if now > claims.get("exp", 0) + CLOCK_SKEW:
raise ValueError("the token has expired")
if now < claims.get("nbf", 0) - CLOCK_SKEW:
raise ValueError("the token is not valid yet")
# 5. Nonce: made for this sign-in, not replayed from an older one?
if not hmac.compare_digest(str(claims.get("nonce", "")), expected_nonce):
raise ValueError("nonce does not match this sign-in")
return claims
# ---------------------------------------------------------------------------
# 3. What the app remembers (in memory; with several copies use Redis or a database)
# ---------------------------------------------------------------------------
pending = {} # state -> {"nonce", "verifier", "created"}: sign-ins in progress
sessions = {} # session cookie value -> {"claims", "id_token"}
lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
url = urlsplit(self.path)
q = {k: v[0] for k, v in parse_qs(url.query).items()}
route = {
"/": self.home,
"/login": self.login,
"/auth/callback": self.callback,
"/logout": self.logout,
"/auth/frontchannel-logout": self.frontchannel_logout,
"/admin": self.admin,
"/api/me": self.api_me,
"/health": self.health,
}.get(url.path)
route(q) if route else self.page(404, "Not found", "<p>No page here.</p>")
# ----- start a sign-in: send the browser to the IdP -----
def login(self, q):
state = secrets.token_urlsafe(16) # ties the answer to this browser (stops CSRF)
nonce = secrets.token_urlsafe(16) # ties the ID token to this sign-in (stops replay)
verifier = secrets.token_urlsafe(48) # PKCE: only this server knows it
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
with lock:
pending[state] = {"nonce": nonce, "verifier": verifier, "created": time.time()}
params = {
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"response_mode": "query",
"scope": SCOPES,
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
if q.get("prompt") in ("login", "none", "select_account"):
params["prompt"] = q["prompt"]
log(f"/login: new sign-in (state {state[:6]}..., nonce {nonce[:6]}...) "
"-> send the browser to the IdP")
self.redirect(metadata()["authorization_endpoint"] + "?" + urlencode(params),
[f"login_state={state}; Path=/auth; Max-Age=600; HttpOnly; SameSite=Lax"])
# ----- the browser comes back from the IdP with a code -----
def callback(self, q):
if "error" in q:
log(f"/auth/callback: the IdP sent an error: {q['error']}")
return self.page(400, "Sign-in failed", f"<p><b>{html.escape(q['error'])}</b>: "
f"{html.escape(q.get('error_description', ''))}</p>")
state, mine = q.get("state", ""), self.cookie("login_state") or ""
if not state or not hmac.compare_digest(state, mine):
log("/auth/callback: state does not match this browser's login_state cookie -> refuse")
return self.page(400, "Sign-in refused", "<p>This answer belongs to a sign-in that "
"this browser did not start (the <code>state</code> does not "
"match).</p>")
with lock:
flow = pending.pop(state, None)
if not flow or time.time() - flow["created"] > 600:
return self.page(400, "Sign-in expired", "<p>Please sign in again.</p>")
log("/auth/callback: state matches this browser's cookie -> OK")
# Back channel: server to server, with the secret and the PKCE verifier.
tokens = fetch_json(metadata()["token_endpoint"], {
"grant_type": "authorization_code",
"code": q.get("code", ""),
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code_verifier": flow["verifier"],
})
if "id_token" not in tokens:
log(f"token endpoint refused: {tokens.get('error')}: {tokens.get('error_description')}")
return self.page(400, "Sign-in failed", f"<p>The token endpoint said: "
f"<b>{html.escape(str(tokens.get('error')))}</b> "
f"{html.escape(str(tokens.get('error_description', '')))}</p>")
log("back channel: swapped the code for an ID token at the token endpoint")
try:
claims = validate_id_token(tokens["id_token"], flow["nonce"])
except ValueError as e:
log(f"ID token rejected: {e}")
return self.page(401, "Sign-in refused", f"<p>{html.escape(str(e))}</p>")
log("ID token checks: signature OK, iss OK, aud OK, exp OK, nonce OK")
session_id = secrets.token_urlsafe(32)
with lock:
sessions[session_id] = {"claims": claims, "id_token": tokens["id_token"]}
log(f"signed in {claims.get('name')} (oid {claims.get('oid')}) -> app session cookie set")
self.redirect("/", [f"session={session_id}; Path=/; HttpOnly; SameSite=Lax",
"login_state=; Path=/auth; Max-Age=0"])
# ----- sign-out -----
def logout(self, q):
with lock:
sessions.pop(self.cookie("session") or "", None)
log("/logout: app session ended -> send the browser to the IdP to end its session too")
url = metadata()["end_session_endpoint"] + "?" + urlencode(
{"post_logout_redirect_uri": PUBLIC_URL + "/", "client_id": CLIENT_ID})
self.redirect(url, ["session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"])
def frontchannel_logout(self, q):
if q.get("iss") != ISSUER:
return self.page(400, "Wrong issuer", "<p>Unknown issuer.</p>")
with lock:
gone = [k for k, s in sessions.items() if s["claims"].get("sid") == q.get("sid")]
for k in gone:
del sessions[k]
log(f"front-channel logout from the IdP: ended {len(gone)} session(s)")
self.page(200, "Signed out", "<p>Signed out.</p>")
# ----- pages -----
def current_session(self):
session = sessions.get(self.cookie("session") or "")
if session and time.time() > session["claims"]["exp"]:
return None # the session lasts as long as the ID token
return session
def home(self, q):
session = self.current_session()
other = (f" · <a href='{html.escape(OTHER_APP_URL)}'>Open the other app</a>"
if OTHER_APP_URL else "")
if not session:
return self.page(200, "Not signed in", "<p>This app does not know who you are.</p>"
f"<p><a class='btn' href='/login'>Sign in with Mini Entra</a>{other}</p>")
c = session["claims"]
rows = "".join(f"<tr><th>{html.escape(k)}</th><td>{html.escape(fmt(k, v))}</td></tr>"
for k, v in c.items())
h, p, s = session["id_token"].split(".")
self.page(200, f"Hello, {html.escape(c.get('name', ''))}",
f"<p>Signed in as <b>{html.escape(c.get('preferred_username', ''))}</b>.</p>"
f"<p><a href='/admin'>Admin page</a> · <a href='/api/me'>/api/me</a>{other} · "
f"<a href='/logout'>Sign out</a></p>"
f"<h2>Claims in the ID token</h2><table>{rows}</table>"
f"<h2>The ID token itself</h2><p class='jwt'><span class='h'>{h}</span>."
f"<span class='p'>{p}</span>.<span class='s'>{s}</span></p>")
def admin(self, q):
session = self.current_session()
if not session:
return self.redirect("/login")
roles = session["claims"].get("roles", [])
if "Admin" not in roles:
log(f"/admin: {session['claims'].get('name')} has roles {roles} -> 403")
return self.page(403, "Forbidden", "<p>You are signed in, but you do not have the "
"<b>Admin</b> role. <a href='/'>Back</a></p>")
self.page(200, "Admin page", "<p>You have the <b>Admin</b> role. <a href='/'>Back</a></p>")
def api_me(self, q):
session = self.current_session()
if not session:
return self.json(401, {"error": "not signed in"})
self.json(200, session["claims"])
def health(self, q):
self.json(200, {"status": "ok"})
# ----- helpers -----
def cookie(self, name):
jar = SimpleCookie(self.headers.get("Cookie", ""))
return jar[name].value if name in jar else None
def redirect(self, location, cookies=()):
self.send_response(302)
self.send_header("Location", location)
for c in cookies:
self.send_header("Set-Cookie", c)
self.send_header("Content-Length", "0")
self.end_headers()
def json(self, status, obj):
body = json.dumps(obj, indent=2).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def page(self, status, title, body):
doc = (PAGE.replace("{{COLOR}}", COLOR).replace("{{APP}}", html.escape(APP_NAME))
.replace("{{TITLE}}", title).replace("{{BODY}}", body)).encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(doc)))
self.end_headers()
self.wfile.write(doc)
def fmt(key, value):
if key in ("iat", "nbf", "exp"):
when = datetime.fromtimestamp(value, timezone.utc).strftime("%H:%M:%S UTC")
return f"{value} ({when})"
return ", ".join(value) if isinstance(value, list) else str(value)
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{APP}}</title>
<style>
body{font:16px/1.5 system-ui,sans-serif;margin:0;background:#f6f7f7;color:#1b2222}
header{background:{{COLOR}};color:#fff;padding:14px 20px;font-weight:600}
main{max-width:760px;margin:0 auto;padding:8px 20px 40px}
a{color:{{COLOR}}}.btn{display:inline-block;background:{{COLOR}};color:#fff;padding:8px 16px;
border-radius:4px;text-decoration:none;font-weight:600}
table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;
padding:5px 8px;border-bottom:1px solid #dde2e2;vertical-align:top}th{width:28%;
font-family:ui-monospace,Menlo,monospace;font-weight:500}td{word-break:break-all}
.jwt{font:12px/1.5 ui-monospace,Menlo,monospace;word-break:break-all;background:#fff;
border:1px solid #dde2e2;padding:10px}.h{color:#b23a48}.p{color:#6a3fa0}.s{color:#1f6fb2}
h2{font-size:17px;margin-top:28px}
</style></head><body><header>{{APP}}</header><main><h1>{{TITLE}}</h1>{{BODY}}</main></body></html>"""
if __name__ == "__main__":
log(f"listening on {PUBLIC_URL}; trusts issuer {ISSUER}")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()Fileidp/idp.py: Mini Entra
"""mini-entra: a small OpenID Connect provider that stands in for Microsoft Entra ID.
It does the identity provider's part of a sign-in, in code you can read:
- keeps users and app registrations (directory.json),
- shows a sign-in page and checks the password,
- remembers who signed in with its own session cookie (this is what makes SSO work),
- hands out single-use authorization codes,
- signs ID tokens with an RSA private key and publishes the public key.
The URL paths copy Entra's, so what you see here is what you will see there.
Teaching code only: plain HTTP, test passwords in a file, the key lives in memory.
"""
import base64
import hashlib
import hmac
import html
import json
import os
import secrets
import threading
import time
import uuid
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlsplit
PUBLIC_URL = os.getenv("PUBLIC_URL", "http://idp.localhost:9000")
with open(os.path.join(os.path.dirname(__file__), "directory.json")) as f:
DIRECTORY = json.load(f)
TID = DIRECTORY["tenant_id"]
ISSUER = f"{PUBLIC_URL}/{TID}/v2.0"
USERS = {u["username"]: u for u in DIRECTORY["users"]}
APPS = {a["client_id"]: a for a in DIRECTORY["app_registrations"]}
CODE_LIFETIME = 60 # seconds; Entra's authorization codes also last about a minute
TOKEN_LIFETIME = 3600 # seconds; Entra's ID tokens last one hour
def log(msg):
print(f"[mini-entra] {msg}", flush=True)
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
# ---------------------------------------------------------------------------
# 1. The signing key pair (RSA, 2048 bits), made fresh at every start.
# The private exponent d never leaves this process. n and e are public.
# ---------------------------------------------------------------------------
def _probably_prime(n, rounds=40):
for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47):
if n % p == 0:
return n == p
d, s = n - 1, 0
while d % 2 == 0:
d, s = d // 2, s + 1
for _ in range(rounds): # Miller-Rabin test
x = pow(secrets.randbelow(n - 3) + 2, d, n)
if x in (1, n - 1):
continue
for _ in range(s - 1):
x = pow(x, 2, n)
if x == n - 1:
break
else:
return False
return True
def _random_prime(bits):
while True:
candidate = secrets.randbits(bits) | (3 << (bits - 2)) | 1
if _probably_prime(candidate):
return candidate
def new_rsa_key(bits=2048):
e = 65537
while True:
p, q = _random_prime(bits // 2), _random_prime(bits // 2)
phi = (p - 1) * (q - 1)
try:
d = pow(e, -1, phi)
except ValueError:
continue
if p != q:
return p * q, e, d
N, E, D = new_rsa_key()
K = (N.bit_length() + 7) // 8
KID = b64url(hashlib.sha256(N.to_bytes(K, "big")).digest())[:27]
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
log(f"made a new RSA signing key, kid={KID}")
def rs256_sign(data: bytes) -> bytes:
"""RSASSA-PKCS1-v1_5 with SHA-256, the algorithm called RS256 in a JWT."""
t = SHA256_DIGEST_INFO + hashlib.sha256(data).digest()
encoded = b"\x00\x01" + b"\xff" * (K - len(t) - 3) + b"\x00" + t
return pow(int.from_bytes(encoded, "big"), D, N).to_bytes(K, "big")
def sign_jwt(claims: dict) -> str:
header = {"typ": "JWT", "alg": "RS256", "kid": KID}
part = lambda obj: b64url(json.dumps(obj, separators=(",", ":")).encode())
signing_input = part(header) + "." + part(claims)
return signing_input + "." + b64url(rs256_sign(signing_input.encode()))
def pairwise_sub(user, app):
"""sub is different for every app; oid is the same everywhere (as in Entra)."""
seed = f"{TID}:{app['client_id']}:{user['oid']}".encode()
return b64url(hashlib.sha256(seed).digest())[:43]
# ---------------------------------------------------------------------------
# 2. What the IdP remembers (in memory; a restart forgets everything)
# ---------------------------------------------------------------------------
sessions = {} # IdP session cookie value -> {"user", "sid", "apps"}
pending = {} # sign-in page id -> the /authorize parameters while the page is open
codes = {} # authorization code -> what it was issued for
lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # print our own lines instead of an access log
pass
# ----- routing -----
def do_GET(self):
url = urlsplit(self.path)
q = {k: v[0] for k, v in parse_qs(url.query).items()}
route = {
"/": self.home,
f"/{TID}/v2.0/.well-known/openid-configuration": self.discovery,
f"/{TID}/discovery/v2.0/keys": self.jwks,
f"/{TID}/oauth2/v2.0/authorize": self.authorize,
f"/{TID}/oauth2/v2.0/logout": self.logout,
}.get(url.path)
route(q) if route else self.send_text(404, "not found")
def do_POST(self):
url = urlsplit(self.path)
length = int(self.headers.get("Content-Length") or 0)
form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode()).items()}
route = {
f"/{TID}/login": self.login,
f"/{TID}/oauth2/v2.0/token": self.token,
}.get(url.path)
route(form) if route else self.send_text(404, "not found")
# ----- public metadata -----
def discovery(self, q):
self.send_json(200, {
"issuer": ISSUER,
"authorization_endpoint": f"{PUBLIC_URL}/{TID}/oauth2/v2.0/authorize",
"token_endpoint": f"{PUBLIC_URL}/{TID}/oauth2/v2.0/token",
"jwks_uri": f"{PUBLIC_URL}/{TID}/discovery/v2.0/keys",
"end_session_endpoint": f"{PUBLIC_URL}/{TID}/oauth2/v2.0/logout",
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"scopes_supported": ["openid", "profile"],
"subject_types_supported": ["pairwise"],
"id_token_signing_alg_values_supported": ["RS256"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"],
"frontchannel_logout_supported": True,
"claims_supported": ["aud", "exp", "iat", "iss", "name", "nbf", "nonce", "oid",
"preferred_username", "roles", "sid", "sub", "tid", "ver"],
})
def jwks(self, q):
self.send_json(200, {"keys": [{
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": KID,
"n": b64url(N.to_bytes(K, "big")), "e": b64url(E.to_bytes(3, "big")),
}]})
# ----- step 1 of a sign-in: the browser arrives from an app -----
def authorize(self, q):
app = APPS.get(q.get("client_id", ""))
if not app:
return self.error_page("AADSTS700016", f"Application with identifier "
f"'{q.get('client_id', '')}' was not found in the directory "
f"'{DIRECTORY['tenant_name']}'.")
name, redirect_uri = app["display_name"], q.get("redirect_uri", "")
if redirect_uri not in app["redirect_uris"]:
log(f"/authorize from {name}: redirect_uri {redirect_uri} is not registered "
"-> refuse, and do NOT send the browser there")
return self.error_page("AADSTS50011", f"The redirect URI '{redirect_uri}' specified "
"in the request does not match the redirect URIs configured "
f"for the application '{app['client_id']}'.")
# The redirect URI is trusted from here on, so errors can go back to the app.
if q.get("response_type") != "code":
return self.redirect_error(q, "unsupported_response_type", "Use response_type=code.")
if "openid" not in q.get("scope", "").split():
return self.redirect_error(q, "invalid_request", "The scope must include openid.")
if q.get("code_challenge") and q.get("code_challenge_method") != "S256":
return self.redirect_error(q, "invalid_request", "Use code_challenge_method=S256.")
session = self.current_session()
prompt = q.get("prompt")
if session and prompt != "login":
log(f"/authorize from {name}: this browser already has an IdP session for "
f"{session['user']} -> no sign-in page. This is single sign-on.")
return self.finish_sign_in(session, q, app)
if prompt == "none":
return self.redirect_error(q, "login_required", "prompt=none, but nobody is signed in.")
page_id = secrets.token_urlsafe(16)
with lock:
pending[page_id] = q
why = "prompt=login" if session else "no IdP session for this browser"
log(f"/authorize from {name}: {why} -> show the sign-in page")
self.login_page(page_id, app)
# ----- step 2: the user submits the sign-in form -----
def login(self, form):
page_id = form.get("page_id", "")
q = pending.get(page_id)
if not q:
return self.error_page(None, "This sign-in page has expired. Go back to the app and "
"sign in again.")
app = APPS[q["client_id"]]
user = USERS.get(form.get("username", "").strip().lower())
if not user or not hmac.compare_digest(user["password"], form.get("password", "")):
log("sign-in failed: wrong username or password")
return self.login_page(page_id, app, error="Wrong username or password.")
with lock:
pending.pop(page_id, None)
cookie_value = secrets.token_urlsafe(32)
session = {"user": user["username"], "sid": str(uuid.uuid4()), "apps": set()}
sessions[cookie_value] = session
log(f"{user['username']} signed in with a password -> IdP session cookie set "
"for idp.localhost")
cookie = f"idp_session={cookie_value}; Path=/; HttpOnly; SameSite=Lax"
self.finish_sign_in(session, q, app, cookie)
# ----- step 3: give the app a single-use code, through the browser -----
def finish_sign_in(self, session, q, app, cookie=None):
user = USERS[session["user"]]
if app["assignment_required"] and user["username"] not in app["assignments"]:
log(f"{user['username']} is not assigned to {app['display_name']} "
"(assignment required) -> refuse")
return self.error_page("AADSTS50105", f"The signed in user '{user['username']}' is "
f"not assigned to the application {app['display_name']}. "
"Your administrator set this application to block users "
"who were not assigned to it.", cookie)
code = secrets.token_urlsafe(32)
with lock:
codes[code] = {
"client_id": app["client_id"], "redirect_uri": q["redirect_uri"],
"user": user["username"], "sid": session["sid"], "nonce": q.get("nonce"),
"scope": q.get("scope", ""), "challenge": q.get("code_challenge"),
"expires": time.time() + CODE_LIFETIME, "used": False,
}
session["apps"].add(app["client_id"])
sep = "&" if "?" in q["redirect_uri"] else "?"
location = q["redirect_uri"] + sep + urlencode({"code": code, "state": q.get("state", "")})
log(f"code {code[:6]}... issued to {app['display_name']} for {user['username']} "
f"(single use, valid {CODE_LIFETIME} s) -> send the browser back to the app")
self.redirect(location, cookie)
# ----- step 4: the app's server swaps the code for tokens (back channel) -----
def token(self, form):
client_id, secret = form.get("client_id", ""), form.get("client_secret", "")
auth = self.headers.get("Authorization", "")
if auth.startswith("Basic "): # client_secret_basic: id and secret in a header
client_id, _, secret = base64.b64decode(auth[6:]).decode().partition(":")
app = APPS.get(client_id)
if not app:
return self.token_error(400, "invalid_client", "AADSTS700016",
f"Application '{client_id}' was not found in the directory.")
if not hmac.compare_digest(secret, app["client_secret"]):
log(f"/token: wrong client secret from {app['display_name']} -> refuse")
return self.token_error(401, "invalid_client", "AADSTS7000215",
"Invalid client secret provided.")
if form.get("grant_type") != "authorization_code":
return self.token_error(400, "unsupported_grant_type", None,
"This lab supports grant_type=authorization_code only.")
with lock:
entry = codes.get(form.get("code", ""))
if not entry:
return self.token_error(400, "invalid_grant", None, "The code is not known.")
if entry["used"]:
log(f"/token: {app['display_name']} sent a code that was already used -> refuse")
return self.token_error(400, "invalid_grant", "AADSTS54005",
"OAuth2 Authorization code was already redeemed.")
entry["used"] = True
if time.time() > entry["expires"]:
return self.token_error(400, "invalid_grant", "AADSTS70008",
"The provided authorization code has expired.")
if entry["client_id"] != client_id or entry["redirect_uri"] != form.get("redirect_uri"):
return self.token_error(400, "invalid_grant", None,
"The code was issued to another app or redirect URI.")
if entry["challenge"]:
verifier = form.get("code_verifier", "")
if b64url(hashlib.sha256(verifier.encode()).digest()) != entry["challenge"]:
log(f"/token: PKCE code_verifier from {app['display_name']} does not match -> refuse")
return self.token_error(400, "invalid_grant", "AADSTS501481", "The Code_Verifier "
"does not match the code_challenge supplied in the "
"authorization request.")
user, now = USERS[entry["user"]], int(time.time())
claims = {
"aud": app["client_id"],
"iss": ISSUER,
"iat": now,
"nbf": now,
"exp": now + TOKEN_LIFETIME,
"name": user["name"],
"oid": user["oid"],
"preferred_username": user["username"],
"sid": entry["sid"],
"sub": pairwise_sub(user, app),
"tid": TID,
"ver": "2.0",
}
if entry["nonce"]:
claims["nonce"] = entry["nonce"]
roles = app["assignments"].get(user["username"], [])
if roles:
claims["roles"] = roles
log(f"/token: {app['display_name']} swapped the code for tokens: secret OK, "
f"PKCE OK -> ID token for {user['username']} signed with key {KID}")
self.send_json(200, {
"token_type": "Bearer",
"scope": entry["scope"],
"expires_in": TOKEN_LIFETIME,
"access_token": "lab-opaque-" + secrets.token_urlsafe(24),
"id_token": sign_jwt(claims),
}, extra=[("Cache-Control", "no-store")])
# ----- sign-out -----
def logout(self, q):
cookie_value = self.cookie("idp_session")
with lock:
session = sessions.pop(cookie_value, None) if cookie_value else None
target = q.get("post_logout_redirect_uri")
app = APPS.get(q.get("client_id", ""))
if target and not (app and target in app["redirect_uris"]):
return self.error_page(None, f"post_logout_redirect_uri '{target}' is not a "
"registered redirect URI of this app.")
frames = []
if session:
for client_id in sorted(session["apps"]):
url = APPS[client_id].get("frontchannel_logout_url")
if url:
frames.append(url + "?" + urlencode({"iss": ISSUER, "sid": session["sid"]}))
log(f"logout: ended the IdP session of {session['user']}; telling {len(frames)} "
"app(s) through their front-channel logout URLs")
else:
log("logout: this browser had no IdP session")
clear = "idp_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"
self.signed_out_page(frames, target, clear)
# ----- helpers -----
def cookie(self, name):
jar = SimpleCookie(self.headers.get("Cookie", ""))
return jar[name].value if name in jar else None
def current_session(self):
value = self.cookie("idp_session")
return sessions.get(value) if value else None
def redirect_error(self, q, error, description):
sep = "&" if "?" in q["redirect_uri"] else "?"
self.redirect(q["redirect_uri"] + sep + urlencode(
{"error": error, "error_description": description, "state": q.get("state", "")}))
def redirect(self, location, cookie=None):
self.send_response(302)
self.send_header("Location", location)
if cookie:
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Length", "0")
self.end_headers()
def send_json(self, status, obj, extra=()):
body = json.dumps(obj, indent=2).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
for k, v in extra:
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def token_error(self, status, error, entra_code, text):
description = f"{entra_code}: {text}" if entra_code else text
self.send_json(status, {"error": error, "error_description": description})
def send_text(self, status, text):
self.send_html(status, f"<p>{html.escape(text)}</p>")
def send_html(self, status, body, cookie=None):
page = PAGE.replace("{{BODY}}", body).encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
if cookie:
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Length", str(len(page)))
self.end_headers()
self.wfile.write(page)
def error_page(self, entra_code, text, cookie=None):
code_line = (f"<p class='code'>The same error in Entra: <b>{entra_code}</b></p>"
if entra_code else "")
self.send_html(400, f"<h1>Sign-in stopped</h1><p>{html.escape(text)}</p>{code_line}",
cookie)
def login_page(self, page_id, app, error=""):
err = f"<p class='err'>{html.escape(error)}</p>" if error else ""
self.send_html(200, f"""
<h1>Sign in</h1>
<p>to continue to <b>{html.escape(app['display_name'])}</b></p>{err}
<form method="post" action="/{TID}/login">
<input type="hidden" name="page_id" value="{page_id}">
<label>Username <input name="username" value="alice@lab.test" autocomplete="off"></label>
<label>Password <input name="password" type="password" value="lab-password"></label>
<button>Sign in</button>
</form>
<p class="hint">Test users: <code>alice@lab.test</code> and <code>bob@lab.test</code>,
password <code>lab-password</code>. Only this page sees the password; the app never does.</p>""")
def signed_out_page(self, frames, target, cookie):
iframes = "".join(f'<iframe src="{html.escape(u)}" hidden></iframe>' for u in frames)
go = (f"<p><a href='{html.escape(target)}'>Continue</a></p>"
f"<script>setTimeout(() => location.href = {json.dumps(target)}, 1500)</script>"
if target else "")
self.send_html(200, f"<h1>You signed out</h1><p>Mini Entra ended your session and "
f"told {len(frames)} app(s) to end theirs.</p>{iframes}{go}", cookie)
def home(self, q):
rows = "".join(f"<li>{html.escape(a['display_name'])}: <code>{a['client_id']}</code></li>"
for a in APPS.values())
self.send_html(200, f"""<h1>Mini Entra is running</h1>
<p>Tenant <b>{html.escape(DIRECTORY['tenant_name'])}</b> <code>{TID}</code></p>
<p><a href="/{TID}/v2.0/.well-known/openid-configuration">Discovery document</a> ·
<a href="/{TID}/discovery/v2.0/keys">Public signing keys</a></p>
<p>App registrations:</p><ul>{rows}</ul>""")
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Mini Entra</title>
<style>
body{font:16px/1.5 system-ui,sans-serif;background:#eef0f7;color:#1c2133;margin:0;
padding:48px 16px}
main{max-width:420px;margin:0 auto;background:#fff;border:1px solid #d3d7e6;border-radius:6px;
padding:28px 28px 20px}
.brand{font:600 13px/1 system-ui;letter-spacing:.08em;text-transform:uppercase;color:#3d4a9e}
h1{font-size:24px;margin:10px 0 4px}
label{display:block;margin:14px 0 0;font-size:14px;color:#4b5270}
input{display:block;width:100%;box-sizing:border-box;font:inherit;padding:8px 10px;
border:1px solid #b9bfd6;border-radius:4px;margin-top:4px}
button{margin-top:20px;font:inherit;font-weight:600;background:#3d4a9e;color:#fff;border:0;
border-radius:4px;padding:9px 22px;cursor:pointer}
.hint{font-size:13px;color:#5b6283;border-top:1px solid #e3e6f0;margin-top:24px;padding-top:12px}
.err{color:#a3261b}.code{font-size:14px;color:#5b6283}
code{font-size:13px;background:#eef0f7;padding:1px 4px;border-radius:3px}
</style></head><body><main><div class="brand">Mini Entra · lab identity provider</div>
{{BODY}}</main></body></html>"""
if __name__ == "__main__":
log(f"issuer {ISSUER}")
ThreadingHTTPServer(("0.0.0.0", 9000), Handler).serve_forever()Fileidp/directory.json: the tenant, its users and app registrations
{
"tenant_id": "0f1e2d3c-4b5a-4697-8877-665544332211",
"tenant_name": "Lab Tenant",
"users": [
{
"username": "alice@lab.test",
"password": "lab-password",
"name": "Alice Example",
"oid": "aaaaaaaa-1111-4111-8111-111111111111"
},
{
"username": "bob@lab.test",
"password": "lab-password",
"name": "Bob Example",
"oid": "bbbbbbbb-2222-4222-8222-222222222222"
}
],
"app_registrations": [
{
"display_name": "App A",
"client_id": "a0000000-0000-4000-8000-00000000000a",
"client_secret": "lab-secret-for-app-a",
"redirect_uris": [
"http://app-a.localhost:8001/auth/callback",
"http://app-a.localhost:8001/"
],
"frontchannel_logout_url": "http://app-a.localhost:8001/auth/frontchannel-logout",
"assignment_required": false,
"assignments": {
"alice@lab.test": ["Admin"]
}
},
{
"display_name": "App B",
"client_id": "b0000000-0000-4000-8000-00000000000b",
"client_secret": "lab-secret-for-app-b",
"redirect_uris": [
"http://app-b.localhost:8002/auth/callback",
"http://app-b.localhost:8002/"
],
"frontchannel_logout_url": "http://app-b.localhost:8002/auth/frontchannel-logout",
"assignment_required": true,
"assignments": {
"alice@lab.test": []
}
}
]
}Filesecrets/: the two client secrets
secrets/app-a-secret.txt contains lab-secret-for-app-a and secrets/app-b-secret.txt contains lab-secret-for-app-b, each on one line. They are fake values for the lab. Compose mounts them at /run/secrets/app_a_secret and /run/secrets/app_b_secret.
14From the lab to real Entra: your project plan
Why you need thisMost of an SSO project is decisions and requests to other people, not code. This section is the list to work through with your team and your Entra administrator, then the code and configuration to start from.
14.1Questions to settle first
Tick them off as you get answers; the page remembers your ticks in this browser.
14.2Registering an app in the Entra admin center
If you may register apps yourself (§8.7), these are the steps for one app in one environment. Otherwise, send this list to your administrator.
- Open
entra.microsoft.com, then Entra ID › App registrations › New registration. - Name: expenses-prod. Supported account types: single tenant. Redirect URI: platform Web,
https://expenses.company.com/auth/callback. Select Register. - On Overview, copy the Application (client) ID and the Directory (tenant) ID. Neither is secret.
- Authentication: add any other redirect URIs (for sign-out, the page users return to, such as
https://expenses.company.com/) and the front-channel logout URL. Leave the implicit-grant boxes unticked. - Certificates & secrets: upload a certificate, or create a client secret with an expiry under 12 months. Copy the secret's Value (not its Secret ID) at once, straight into your secret store; it is never shown again.
- App roles: create your roles (value
Admin, allowed member types Users/Groups, and so on). - Token configuration: add optional claims you need, such as
email. - API permissions: check that
User.Read(or justopenidandprofile) is listed; if your company requires it, ask for admin consent. - Enterprise apps › expenses-prod › Properties: Assignment required? Yes, if not everyone should get in.
- Users and groups: assign the people or groups, with their roles.
- Sign in with an ordinary (non-admin) test account, then read the attempt under Sign-in logs.
14.3Pattern A in code: FastAPI with Authlib
This is the starting point for an app of your own. Authlib reads Entra's discovery document, creates and checks state and nonce, does PKCE, swaps the code, and checks the ID token with the list in §7.11, refreshing the keys when it meets a new kid. It keeps the in-progress values and the user in Starlette's session cookie, which is signed (not encrypted), so it works with several copies as long as they share SESSION_KEY (§12.3).
Not yet run
Unlike the lab, this code has not been run: not against Entra (no tenant was available) and not against Mini Entra (it needs packages that were not downloaded). It follows Authlib's documented Starlette integration. Run it against the lab first (Mini Entra is a standard OpenID provider), then against your development registration.
requirements.txt
fastapi==0.141.1 uvicorn==0.54.0 authlib==1.8.0 httpx==0.28.1 itsdangerous==2.2.0
main.py
import os
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
def secret(name):
with open(os.environ[name]) as f:
return f.read().strip()
TENANT_ID = os.environ["ENTRA_TENANT_ID"]
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
CLIENT_ID = os.environ["ENTRA_CLIENT_ID"]
REDIRECT_URI = os.environ["REDIRECT_URI"] # https://expenses.company.com/auth/callback
HOME_URL = os.environ["HOME_URL"] # https://expenses.company.com/ (also registered)
app = FastAPI()
app.add_middleware(
SessionMiddleware,
secret_key=secret("SESSION_KEY_FILE"), # the same on every copy of the app
session_cookie="session",
max_age=3600, # app session: one hour
same_site="lax",
https_only=os.getenv("COOKIE_SECURE", "true") == "true",
)
oauth = OAuth()
oauth.register(
name="entra",
server_metadata_url=f"{AUTHORITY}/v2.0/.well-known/openid-configuration",
client_id=CLIENT_ID,
client_secret=secret("ENTRA_CLIENT_SECRET_FILE"),
client_kwargs={"scope": "openid profile email", "code_challenge_method": "S256"},
)
@app.get("/login")
async def login(request: Request):
return await oauth.entra.authorize_redirect(request, REDIRECT_URI)
@app.get("/auth/callback")
async def auth_callback(request: Request):
try:
token = await oauth.entra.authorize_access_token(request)
except OAuthError as e:
return JSONResponse({"error": e.error, "description": e.description}, status_code=400)
claims = token["userinfo"] # the checked ID token's claims
request.session["user"] = {
"oid": claims["oid"], # the key for your database (§9.5)
"tid": claims["tid"],
"name": claims.get("name"),
"roles": claims.get("roles", []),
}
return RedirectResponse("/")
@app.get("/logout")
async def logout(request: Request):
request.session.clear() # 1. end the app's session
return RedirectResponse( # 2. end Entra's session
f"{AUTHORITY}/oauth2/v2.0/logout?post_logout_redirect_uri={HOME_URL}")
@app.get("/")
async def home(request: Request):
user = request.session.get("user")
if not user:
return RedirectResponse("/login")
return {"hello": user["name"], "roles": user["roles"]}
@app.get("/health") # no sign-in: the load balancer calls it
def health():
return {"status": "ok"}The role check from §9.2 plugs straight into this app. In compose.yaml, give the container ENTRA_TENANT_ID, ENTRA_CLIENT_ID, REDIRECT_URI and HOME_URL as environment variables, and the client secret and session key as Compose secrets (ENTRA_CLIENT_SECRET_FILE=/run/secrets/entra_client_secret, SESSION_KEY_FILE=/run/secrets/session_key).
14.4Pattern B in configuration: oauth2-proxy behind Caddy
Not yet run
Assembled from oauth2-proxy's Entra provider documentation (version 7.15) and Caddy's forward_auth documentation. Test it before relying on it, in particular that identity headers sent by a client are removed.
oauth2-proxy.cfg
provider = "entra-id" oidc_issuer_url = "https://login.microsoftonline.com/<tenant-id>/v2.0" client_id = "<client-id>" client_secret_file = "/run/secrets/entra_client_secret" scope = "openid profile email User.Read" # User.Read: fetch groups when there are over 200 redirect_url = "https://tools.company.com/oauth2/callback" allowed_groups = ["<object-id-of-the-group>"] email_domains = ["*"] reverse_proxy = true set_xauthrequest = true # return X-Auth-Request-User / -Email to Caddy http_address = "0.0.0.0:4180" upstreams = ["static://202"] # cookie_secret: 32 random bytes, given as the OAUTH2_PROXY_COOKIE_SECRET environment variable from a secret
Caddyfile
tools.company.com {
handle /oauth2/* {
reverse_proxy oauth2-proxy:4180
}
handle {
route { # route keeps this order: strip, check, then pass on
request_header -X-Auth-Request-User
request_header -X-Auth-Request-Email
forward_auth oauth2-proxy:4180 {
uri /oauth2/auth
copy_headers X-Auth-Request-User X-Auth-Request-Email
@unauthorized status 401
handle_response @unauthorized {
redir * /oauth2/sign_in?rd={scheme}://{host}{uri}
}
}
reverse_proxy app:8000
}
}
}The redirect URI to register in Entra is https://tools.company.com/oauth2/callback. In Compose, only Caddy publishes ports 80 and 443; app and oauth2-proxy publish none.
14.5Pointing the lab's app at your real tenant
Because the lab's app learns everything from the discovery document, it can sign in against Entra unchanged. That makes a good first test of a development registration:
- Create a registration sso-lab-dev (single tenant, platform Web) with redirect URIs
http://localhost:8001/auth/callbackandhttp://localhost:8001/, and a client secret. - In
compose.yaml, change App A's settings:ISSUER=https://login.microsoftonline.com/your-tenant-id/v2.0,CLIENT_ID=the new client ID,PUBLIC_URL=http://localhost:8001, and put the secret's value intosecrets/app-a-secret.txt. - Start only App A (
podman compose up -d app-a) and openhttp://localhost:8001.
What was checked: the app's discovery and key code was run against Entra's public endpoints for Microsoft's own tenant. It read the discovery document and parsed all six published signing keys (2048-bit RSA). The sign-in itself could not be run without an account in a tenant.
Only for a development registration
The lab's app is teaching code: plain HTTP, sessions in memory, no Secure cookies. Use it to see real Entra tokens in a dev tenant, never in production.
14.6When it fails: the error codes you will meet
Entra shows its errors as AADSTS followed by a number, on its own page or in the token endpoint's JSON. Microsoft keeps the full list in its error code reference; these are the ones a new integration usually meets:
| Code | Meaning | Usual cause and fix |
|---|---|---|
AADSTS50011 | Redirect URI does not match | Register the exact URI. Behind a proxy, check that the app isn't building an http:// address (§12.1); check case and the trailing slash. |
AADSTS700016 | App not found in the directory | Wrong client ID, or the authority names a different tenant. |
AADSTS50105 | User not assigned to the app | Assignment is required; assign the user or one of their groups (§9.1). |
AADSTS65001 | No consent for the requested permissions | An administrator grants admin consent (§8.6). |
AADSTS7000215 | Invalid client secret | Often the secret's ID was used instead of its value, or an old secret. |
AADSTS7000222 | Client secret expired | Create a new one and rotate (§12.2). |
AADSTS54005 | Code already redeemed | The callback ran twice: a retry, a double click, the back button. |
AADSTS70008 | Code or refresh token expired | More than about a minute between the redirect and the token call; or a refresh token unused for too long. |
AADSTS501481 | PKCE verifier does not match | The verifier was lost or mixed up between requests, often with several app copies (§12.3). |
AADSTS9002313 | Request malformed or invalid | A value is garbled, such as a truncated or double-encoded code. (A made-up code sent to Entra's token endpoint returned this on 14 October 2026.) |
AADSTS50076 | Multi-factor authentication required | A Conditional Access policy; the user completes MFA on Entra's page. |
AADSTS50058 | Silent sign-in, but nobody is signed in | prompt=none without an Entra session; fall back to a normal sign-in. |
AADSTS50020 | User from another tenant not in this one | A guest who was not invited, or the authority is wrong. |
AADSTS50057 | User account is disabled | As intended, after someone leaves (§10.3). |
And problems that show up in your app rather than at Entra:
| Symptom | Usual cause |
|---|---|
| "state does not match" or "missing state" | The login cookie did not come back: /login and the callback on different hosts (localhost vs 127.0.0.1); form_post with a SameSite=Lax cookie (§6.3); several copies with state in memory; a Secure cookie over plain HTTP. |
| Endless redirects after a successful sign-in | The session cookie is never stored: Secure over HTTP, cookie larger than about 4 KB, or set for the wrong domain. |
| "Invalid issuer" | The app expects one tenant's issuer but uses common; or a version 1 access token (§11.6). |
| "Token not yet valid" or "expired" right after sign-in | The server's clock is wrong (§12.5). |
| Signature check fails on a Microsoft Graph token | Nothing is broken: tokens for Microsoft's APIs are not for your app to check (§5.4). |
End of chapter
Summary, review and reference
The whole chapter on one page, questions that mix the sections, cards to remember the terms, and where to read further.
15Chapter summary
- Authentication proves who someone is; authorization decides what they may do. An identity provider does the first for every app; each app does the second (§1.3).
- With single sign-on, passwords are typed only at the identity provider. Apps receive a signed statement instead, and one switch in Entra stops new sign-ins everywhere (§1.2).
- HTTP forgets everything between requests. Cookies, which belong to the host that set them, carry sessions; redirects move the browser between sites and carry data in the URL (§2).
- The front channel (the browser) carries only short-lived, single-use values. Tokens and secrets go server to server, through the back channel (§2.5).
- A digital signature made with a private key and checked with the public key proves who wrote a message and that nobody changed it. It does not hide the message (§3.3).
- Entra publishes its public keys as a JWKS and rotates them; apps fetch the keys and pick one by the token's
kid(§3.4). - A JWT is
header.payload.signaturein base64url. Anyone holding it can read it (§4). - OAuth 2.0 gives apps access tokens for APIs. OpenID Connect adds the ID token, made for your app, which is what sign-in uses (§5).
- The authorization code flow: redirect to Entra; the user signs in there; a code comes back through the browser; the server swaps it for tokens with its secret and the PKCE verifier; the app checks the ID token and starts its own session (§6).
- The "single" in single sign-on is Entra's own session cookie: the second app runs the same flow, and Entra skips the password page (§6.4).
- Each parameter stops an attack: PKCE stops stolen codes, exact redirect URIs stop codes sent elsewhere,
statestops forged sign-ins,noncestops replayed tokens, and the signature,iss,audandexpchecks stop forged, foreign and old tokens (§7). - In Entra, the app registration defines the app (client ID, redirect URIs, credentials, app roles) and the enterprise application controls its use in the tenant (assignment, consent, sign-in logs) (§8.3).
- Restrict who may sign in with Assignment required; give roles with app roles and check the
rolesclaim; identify users byoidandtid, never by email (§9). - Entra's session, your app's session and the tokens have separate lifetimes. Disabling an account stops new sign-ins at once, but not open app sessions, so keep those short. Sign-out must end both your session and Entra's (§10).
- Put sign-in in each app with a library (A), in a proxy in front of the apps (B), or in the AWS load balancer (C). Use one registration per app and per environment (§11).
- In containers: set the redirect URI explicitly, keep secrets out of images, share session state between copies, leave
/healthpublic, allow outbound HTTPS to Entra, and never log tokens (§12).
16Review questions
These mix the sections. Each answer explains itself; the score counts first attempts only.
Answered 0 of 12.
1. A colleague proposes that the expense tracker store users' Entra passwords to "sign them in faster". What is wrong with that?
2. Why can't the wiki simply read the expense tracker's session cookie to know who the user is?
3. Your app receives a JWT whose header says "alg":"none" and whose claims look perfect. What should it do?
4. In which step does Entra learn where to send the code?
5. An attacker reads a code from a proxy's log a few seconds after it was issued. What stops them from using it, even if they also knew the client secret?
6. What makes the second app's sign-in skip the password page?
7. Where do you set "only the Finance group may sign in"?
8. A user's email address changes after a name change. Which claim keeps their data attached to them?
9. Behind Caddy, sign-in fails with AADSTS50011 although the registered URI is correct. What is the most likely cause?
10. After scaling an app to two copies, sign-ins fail about half the time with "state does not match". Why?
11. You want a tool where "anyone in the AI team group" may use it, and you can't change the tool's code. Which pattern fits?
12. "Sign out" in your app clears its cookie, but the next "Sign in" happens without a password. What is missing?
17Flashcards
Say the answer out loud before you turn the card; retrieving it is what makes it stick. Cards you mark "Again" come back sooner.
Card 1Interactive
18Glossary
19Further reading
Microsoft's documentation
- OpenID Connect on the Microsoft identity platform: endpoints, sign-in, sign-out and front-channel logout.
- OAuth 2.0 authorization code flow: every parameter of §6.3.
- ID token claims reference and access token claims reference.
- Redirect URI rules and limits.
- Certificates, client secrets and federated credentials.
- Restricting an app to a set of users and assigning users and groups.
- Token lifetimes and refresh tokens.
- Error codes (AADSTS).
- Who may register and manage apps and licensing.
Tools
- Authlib: Starlette and FastAPI client.
- MSAL for Python.
- oauth2-proxy: Microsoft Entra ID provider.
- AWS: authenticating users with an Application Load Balancer.
The standards
- OpenID Connect Core 1.0.
- RFC 6749, OAuth 2.0; RFC 7636, PKCE; RFC 7519, JWT.
- RFC 9700, Best Current Practice for OAuth 2.0 Security: the up-to-date list of attacks and defences behind §7.
20How this chapter was checked
- Run on the test Mac (Podman 6.0.2, Docker Compose 5.5.1,
python:3.12-slim) on 14 October 2026: the whole lab, including every command and output in §13, with curl in zsh and in a Chromium-based browser (sign-in, single sign-on, sign-out with front-channel logout). - Checked independently: a lab ID token's signature with OpenSSL 3.6.3; the lab app's discovery and key code against Entra's real public endpoints.
- Fetched from Entra on the same day: the discovery documents for
organizationsand for Microsoft's own tenant, the public key sets, and the token endpoint's answer to a made-up code (AADSTS9002313). - From documentation (Microsoft Learn, AWS, oauth2-proxy and Caddy, read on 14 October 2026): token lifetimes, claims, redirect URI rules, credential limits, licences, roles, the ALB's behaviour and the pattern B configuration.
- Not run: a sign-in against a real Entra tenant, the Authlib example (§14.3), and the oauth2-proxy and Caddy configuration (§14.4).
- The interactive figures use the Web Crypto API built into browsers. Nothing you type into them leaves the page.
- The chapter was written with Claude, Anthropic's AI model, in October 2026.