Application flows
- Lesson 5
- 55 min
- Module 2: Understand login and tokens
Lesson 5 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsApplication flows
1. Identity and permission basics
2. Understand login and tokens
3. Sessions and application integration
4. Enforce resource access
5. Background work and administration
6. Validate and document the design
What you will be able to do
Sign in step by step with the authorization code flow and PKCE, the flow that every modern application uses. See why the code that passes through the browser is useless to a thief. Then give a background service its own identity, apart from any person.
Before you startWhat to know and to have ready
What you need to know
- The identity provider, the access token and the ID token, from OAuth and OIDC.
- An HTTP redirect (
302with aLocationheader) and aPOSTwith form data, from Build and Deploy Your First API.
What you need on your computer
- The project after OAuth and OIDC: the folder
idp/, PyJWT installed, andpython -m idp initdone. - The provider running in its own terminal:
python -m idp.
This lesson adds one file, scripts/login.py. Every person and every token is made up, from the practice provider on your computer. Tested on macOS (Apple silicon); the Windows commands are written but not tested.
A ticket at the dry cleaner
Section titled “A ticket at the dry cleaner”You leave a suit at the dry cleaner. You get a paper ticket with a number, and the cleaner asks for the last four digits of your phone. A week later, you show the ticket and say the four digits. You get the suit.
The ticket alone is not enough, and that is on purpose. A ticket can fall out of your pocket, and anyone can read its number. A stranger who finds it does not know your four digits. So the stranger gets nothing.
Two things make the pick-up safe: a ticket that anyone may see, and a secret that only you know. You said something about the secret at the start, so the cleaner can check it at the end.
The problem: the browser’s address is not a safe place
Section titled “The problem: the browser’s address is not a safe place”At the end of OAuth and OIDC, you opened the provider’s sign-in page. If you pick Sam on that page, the provider sends the browser to a new address. The browser shows something like this in its address bar:
http://127.0.0.1:8765/callback?code=YbO09uJlww7dMqrtMNfQSaEss-zZ_Iy-4ZPnsmdpQfE&state=practiceThe page does not load, because no program listens on port 8765 now. That is fine. Look at the address instead: the provider’s answer is in it. An address is not private. The browser keeps it in its history, and a server can write it to its log. On a phone, another app can register the same kind of address and receive it.
An older OAuth flow for browser applications, the implicit flow, put the access token itself in this address. Everyone who saw the address could call the API as Sam. The address above carries only a code. But if that code alone were enough to get tokens, the problem would stay the same.
The idea: a code in the browser, tokens on a separate line
Section titled “The idea: a code in the browser, tokens on a separate line”The authorization code flow splits the sign-in into two channels. The browser channel is the paper ticket: everyone may see it, so it carries only a short, one-time code. The second channel is a direct request from the application to the provider: POST /token. That request swaps the code for the tokens, and the browser never sees it.
PKCE (Proof Key for Code Exchange, said “pixie”) is the four phone digits. Before the sign-in starts, the application makes a random secret, the code verifier, and keeps it. It sends only a fingerprint of the verifier, the code challenge, with the first redirect. At the swap, it sends the verifier itself. The provider computes the fingerprint again and compares. A thief who copied the code from the address bar does not have the verifier, so the swap fails.
The fingerprint is SHA-256, a hash function. It turns any text into 32 bytes. The same text always gives the same 32 bytes, but nobody can go back from the bytes to the text. Here is a tiny example with every value visible:
| Step | Value |
|---|---|
| Code verifier (kept by the application) | larkfield-practice-verifier-0123456789-abcdefgh (47 characters) |
| SHA-256 of it (32 bytes, first 8 in hexadecimal) | 50593c8650b2db63… |
| Code challenge = base64url of the 32 bytes (sent first) | UFk8hlCy22MX4XcN9PO8q1bTpbNNi-dHh307J7UAFRA |
The verifier with its last letter changed to i |
challenge U4VI_lRYy3zDRt5RJYomuX04X3LF2_TwdCA08Cof4X0 |
One changed letter gives a completely different challenge. Base64url writes bytes as letters, digits, - and _, so they are safe in an address; Read a token shows how. Check the challenge yourself:
python -c "import base64, hashlib; v = 'larkfield-practice-verifier-0123456789-abcdefgh'; print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('='))"UFk8hlCy22MX4XcN9PO8q1bTpbNNi-dHh307J7UAFRAA real verifier is random, not a phrase: the project makes 64 random characters. The standard allows 43 to 128 characters.
Two more values protect the flow. state is a random value that the application sends and expects back unchanged. A callback with another state did not start in this application, so the application stops. This blocks an attacker who tries to sign the person in to an account that they did not choose. nonce is a random value that the provider copies into the ID token. The application checks it, so an old ID token cannot be replayed into a new sign-in.
Here is the whole flow, as the browser sign-in of Module 3 will run it. The ticket API’s backend plays the application:
Text description of the diagram
Follow the browser’s line on the left. It sees the challenge, the sign-in page, the code and the state, and nothing else. The verifier stays in the backend. The three tokens travel only between the provider and the backend, in the answer to POST /token.
Each token has one job. The ID token tells the application who signed in. The access token goes to the API, for 10 minutes. The refresh token goes back to the provider only, to get new tokens when the access token ends. In this module, scripts.login plays both the browser and the application, so you can see each step. Module 3 builds the browser part into the API.
Larkfield: sign in as Sam, step by step
Section titled “Larkfield: sign in as Sam, step by step”-
In the second terminal (project folder, virtual environment active), copy the sign-in script from the snapshot:
Terminal window cp ../nextia-learning-labs/C23/snapshots/end-of-m02/scripts/login.py scripts/Terminal window Copy-Item ..\nextia-learning-labs\C23\snapshots\end-of-m02\scripts\login.py scripts\ -
Check that the provider still runs in the first terminal. Then sign in as Sam:
Terminal window python -m scripts.login --user usr-sam -
Read the output from the top. It has three numbered steps, then the two tokens, decoded.
Here is the author’s output, in three parts. First, the sign-in:
1. The application makes a PKCE pair and sends you to the provider's sign-in page: code_verifier ZOeRGN_qSkOP...(64 characters) (stays in the application) code_challenge BpOUbcrGHcdQlpwHi4hyZo46UgsphM1Q7TUNZhpeWCE (SHA-256 of the verifier, sent now) GET http://localhost:8400/authorize?response_type=code&client_id=ticket-cli&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallb... -> 2002. You pick usr-sam on the page. The provider sends the browser back with a code: 302 Location: http://127.0.0.1:8765/callback?code=UluH440p1gDlIUNxgQ7-WhEtPZ5I-zFgk3... The state matches the one we sent.3. The application swaps the code and the verifier for tokens (POST /token): 200: token_type Bearer, expires_in 600 s, access_token eyJhbGciOiJSUzI1NiIsImtp...(732 characters) id_token eyJhbGciOiJSUzI1NiIsImtp...(712 characters) refresh_token NlyX75NN...(43 characters)Step 1 is the fingerprint: the challenge travels, the 64-character verifier stays. Step 2 is the paper ticket: a 302 redirect with the code in the address, and the script checks the state. Step 3 is the direct line: one POST /token and three tokens. The access token expires in 600 seconds, 10 minutes.
Then the two tokens, decoded. The script prints their content, but it does not check them. Checking is the API’s job, in Token meaning.
id_token (decoded, not checked): header {"alg": "RS256", "kid": "7HxcBtk4djdrsPzB", "typ": "JWT"} claims { "iss": "http://localhost:8400", "sub": "usr-sam", "aud": "ticket-cli", "exp": 1791611579, "iat": 1791610979, "auth_time": 1791610979, "name": "Sam", "email": "sam@larkfield.example", "email_verified": true, "nonce": "RE-SmzqRfOzTdnM6KCALNQ" }
access_token (decoded, not checked): header {"alg": "RS256", "kid": "7HxcBtk4djdrsPzB", "typ": "at+jwt"} claims { "iss": "http://localhost:8400", "sub": "usr-sam", "aud": "ticket-api", "exp": 1791611579, "nbf": 1791610979, "iat": 1791610979, "jti": "cd296cf053a04ee2be30c7823f5b9a52", "client_id": "ticket-cli", "scope": "tickets:read tickets:write members:manage" }
Saved in .tokens/usr-sam.json.The kid in the header is the ID of the key that signed the token. This output is from another run of the author’s, so its kid differs from the one that idp init printed in the last lesson. Yours is your own key’s ID.
Compare the two tokens line by line. Both say "sub": "usr-sam": the same person. But their aud differs. The ID token is for ticket-cli, the application. The access token is for ticket-api. The ID token has the person’s name, email and the nonce. The access token has the application (client_id) and the scope instead. Neither one names an organization or a role. Their headers differ too: typ is JWT for the ID token and at+jwt for the access token. Read a token explains each field.
The tokens are saved in .tokens/usr-sam.json. Git ignores that folder, because a saved token works for anyone who copies it until it expires.
A thief with the code, and a code used twice
Section titled “A thief with the code, and a code used twice”Now act as the thief. The script can send a wrong verifier: a new random one, as a thief would have to guess:
python -m scripts.login --user usr-sam --wrong-verifier3. The application swaps the code and the verifier for tokens (POST /token): 400 {'error': 'invalid_grant', 'error_description': 'The PKCE code_verifier does not match the code_challenge.'}The code was real and fresh, but without the verifier it gave nothing. The provider also lets a code work only once, and only for 60 seconds. Even the real application cannot use it twice. The test test_a_code_works_once checks that; Your turn lets you see it.
Three callers, three identities
Section titled “Three callers, three identities”Sam’s sign-in is one way to call the API. The project has three registered applications, called clients in OAuth. Each one proves who it is in its own way:
Text description of the diagram
Two words decide each column. A confidential client runs where nobody else can read its files, such as a server, so it can keep a client secret. A public client runs on the person’s own computer or phone, where anyone can read the program. It has no secret, so PKCE is its only proof. The third column has no person at all: the service asks for a token with its own ID and secret, a flow called client credentials.
Get the background service’s own token:
python -m scripts.login --serviceaccess_token (decoded, not checked): header {"alg": "RS256", "kid": "My9O_SxNpIg4Ff10", "typ": "at+jwt"} claims { "iss": "http://localhost:8400", "sub": "export-worker", "aud": "ticket-api", "exp": 1791613197, "nbf": 1791612597, "iat": 1791612597, "jti": "d75fcc850a9d4cf9a2b22246768f8b80", "client_id": "export-worker", "scope": "tickets:read" }
Saved in .tokens/export-worker.json.There is no ID token and no refresh token: nobody signed in. The sub is the service itself, the same as client_id, and its only scope is tickets:read. Your key ID differs from the author’s, because init made your key.
“Why not give the export job Sam’s token?” Amira asks. Kwame answers with the audit in mind. A job that runs as Sam looks like Sam in every log. It stops when Sam’s token ends, and it keeps Sam’s rights after Sam leaves the shop. So keep the identities apart: a person’s token for what the person does now, and a service’s token for the service. Job identity context adds the person for whom a job runs.
Refresh tokens: new tokens without a new sign-in
Section titled “Refresh tokens: new tokens without a new sign-in”An access token lasts 10 minutes, so a short theft does little harm. But nobody wants to sign in every 10 minutes. The refresh token solves this: the application sends it to the provider and gets new tokens. The person does nothing. The practice provider also rotates refresh tokens: each one works once, and the answer contains a new one.
python -m scripts.login --refresh usr-samNew tokens. The old refresh token cannot be used again (rotation).The new tokens follow, decoded, as in the sign-in above.
Rotation turns a stolen refresh token into an alarm. If an old refresh token comes back, two parties have a copy, and the provider cannot tell which one is the thief. So it ends the whole family of tokens from that sign-in. Try it: keep a copy of Sam’s saved tokens, refresh once, put the old copy back, and refresh again with the used refresh token.
cp .tokens/usr-sam.json old-sam.jsonpython -m scripts.login --refresh usr-samcp old-sam.json .tokens/usr-sam.jsonpython -m scripts.login --refresh usr-samCopy-Item .tokens\usr-sam.json old-sam.jsonpython -m scripts.login --refresh usr-samCopy-Item old-sam.json .tokens\usr-sam.jsonpython -m scripts.login --refresh usr-samThe first refresh prints the new tokens. The second one, with the used refresh token, gets:
400 {'error': 'invalid_grant', 'error_description': 'The refresh token was already used or revoked.'}Delete old-sam.json after this, and sign in again (python -m scripts.login --user usr-sam): the provider ended the tokens of the old sign-in.
After that, the newest refresh token of the same sign-in fails too. The test test_a_refresh_token_works_once_and_its_reuse_ends_the_family checks it. Module 3 keeps the refresh token on the server, where the browser cannot reach it.
When to use each flow, and what it costs
Section titled “When to use each flow, and what it costs”- Authorization code with PKCE: for every sign-in by a person, in a browser, on a phone or on the command line. Use PKCE even with a client secret; the standards now recommend it for every client.
- Client credentials: for a service with no person, such as the export worker. Never for an action that a person asked for “right now”: that action needs the person’s identity.
- Refresh tokens: when the sign-in must last longer than one access token. Keep them where only the application can read them.
Two older flows are no longer recommended. The implicit flow put the access token in the address. In the password flow, the application asked for the password itself. Do not use them in new work.
The costs: each sign-in needs a redirect and two round trips. Each confidential client has a secret to protect, and each application needs a registered redirect address. Each of these is a setting that can be wrong. Provider configuration checks them.
Your turn: a code used twice
Section titled “Your turn: a code used twice”Predict first: the script swaps the code for tokens, then sends the same code again with the right verifier. What happens to the second request?
python -m scripts.login --user usr-sam --reuse-codeShow the answer
3. The application swaps the code and the verifier for tokens (POST /token): 200: tokens. Now the same code again: 400 {'error': 'invalid_grant', 'error_description': 'The code is not valid, was used, or expired.'}The first swap gets the tokens. The second one gets invalid_grant, although the code and the verifier are both right. A code is like the dry cleaner’s ticket: the cleaner keeps it after the pick-up. So a code that leaked after the sign-in is worth nothing, and a code that leaked before it lasts at most 60 seconds.
Common mistake
A redirect address that is not registered
Symptom: the browser shows a short error from the provider instead of the sign-in page:
{"error":"invalid_request","error_description":"This redirect URI is not registered for the application."}Cause: the provider sends codes only to addresses that the application registered before. Here the request said redirect_uri=https://evil.example/cb instead of http://127.0.0.1:8765/callback. The same error comes from a smaller difference: localhost instead of 127.0.0.1, another port, or a / at the end. The provider compares the addresses exactly.
Diagnose: compare the redirect_uri in the address with the client’s list in idp/store.py (CLIENTS). For a real provider, compare it with the application’s registration page.
Fix:
- Use exactly the registered address in the application, or register the new one at the provider.
- Never register a pattern such as “any address on this domain”. An exact list is the protection.
The provider answers with an error page, not with a redirect. If it redirected an unknown address with an error, an attacker could use the provider to send people anywhere. The test test_an_unregistered_redirect_uri_is_an_error_page_not_a_redirect checks this.
Check your understanding
Trace Sam's sign-in from the output above. Which values pass through the browser, and which travel only between the application and the provider?
Show the answer
Through the browser, in the redirect to /authorize: the code challenge, the client ID, the redirect address, the scopes and the state. In the redirect back: the code and the state. Only between the application and the provider: the code verifier, in the POST to /token, and the three tokens in its answer. That is the point of the flow: what the browser carries is useless alone.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
A phone app has no client secret. What stops another app on the same phone from using a code that it caught?
Show the answer
PKCE. The real app made the code verifier and sent only its SHA-256 fingerprint, the challenge. To swap the code, the other app needs the verifier itself, which never left the real app. The provider computes the fingerprint of what it receives and refuses a mismatch with invalid_grant, as the –wrong-verifier run shows.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
The nightly export job runs at 02:00. Should it use the access token of the person who planned it, or its own token from client credentials? What would go wrong with the person's token?
Show the answer
Its own token, from client credentials, with only the scope it needs (tickets:read). A person’s access token ends after 10 minutes, so the job would fail or need the person’s refresh token. Every log would show the person, not the job. And the job would keep the person’s rights after the person leaves. The person for whom it runs is recorded separately, and checked when the job runs (Module 5).
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- The authorization code flow sends only a short, one-time code through the browser. The application swaps it for tokens on a direct line (
POST /token). - PKCE: the application keeps a random code verifier and sends its SHA-256 fingerprint, the code challenge. A thief with the code and without the verifier gets
invalid_grant. stateprotects the application against a callback it did not start; thenonceties the ID token to this sign-in.- One sign-in gave three tokens. The ID token is for the application (
audticket-cli), the access token for the API (audticket-api, 600 s), and the refresh token for the provider. - Three clients, three identities:
help-desk-web(confidential),ticket-cli(public, PKCE only) andexport-worker(client credentials,sub= itself). - Refresh tokens rotate; a reused one ends the whole family.
- Next: open a token and see what its signature proves.