Skip to content

Application flows

  • Lesson 5
  • 55 min
  • Module 2: Understand login and tokens

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 readyAbout 55 minutes

What you need to know

What you need on your computer

  • The project after OAuth and OIDC: the folder idp/, PyJWT installed, and python -m idp init done.
  • 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.

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=practice

The 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:

Terminal window
python -c "import base64, hashlib; v = 'larkfield-practice-verifier-0123456789-abcdefgh'; print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('='))"
UFk8hlCy22MX4XcN9PO8q1bTpbNNi-dHh307J7UAFRA

A 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:

A sequence with three participants: the browser with Sam, ticket-api as the backend, and the identity provider. The browser asks for /auth/login. The API makes state, a nonce and a PKCE pair: the code verifier stays secret on the server, and the code challenge is the SHA-256 of the verifier. The API answers with a redirect to the provider, with client_id, redirect_uri, scope, state and code_challenge. The browser opens /authorize, and the provider shows its sign-in page. Sam signs in; only the provider sees this. The provider redirects the browser to /auth/callback with a code and the state. The API checks that the state is its own, then sends a POST to /token with the code, the code verifier and its client secret. The provider checks that the SHA-256 of the verifier equals the challenge, and that the code is unused and under 60 seconds old. It answers with three tokens: an ID token for the application, an access token for ticket-api that lasts 10 minutes, and a refresh token for new tokens later. The API checks the ID token's nonce and keeps the tokens on the server. Module 3 adds a session cookie. A command-line application gets the same tokens and sends Authorization: Bearer with the access token. The browser asks for /v1/me; the API checks the access token's signature, issuer, audience and expiry, and answers 200 with usr-sam, staff at Larkfield. After 10 minutes, the refresh token gets new tokens without the browser.
The authorization code flow with PKCE, with each token labelled. The browser carries only the code; the tokens travel on the direct line between the API and the provider.
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.

  1. 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/
  2. Check that the provider still runs in the first terminal. Then sign in as Sam:

    Terminal window
    python -m scripts.login --user usr-sam
  3. 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... -> 200
2. 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:

Terminal window
python -m scripts.login --user usr-sam --wrong-verifier
3. 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.

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:

Three columns. Browser sign-in: the client help-desk-web is a confidential client, a backend that keeps a secret. It uses the authorization code with PKCE and its client secret. Its tokens say sub usr-sam: a person. Command line: the client ticket-cli is a public client that runs on your computer and has no secret. It uses the authorization code with PKCE, so PKCE is its only proof. Its tokens say sub usr-sam: a person. Background service: the client export-worker is a confidential client with no person behind it. It uses client credentials, its own ID and secret. Its token says sub export-worker: a service, with the scope tickets:read.
The project's three clients. Two act for a person; the background service acts as itself.
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:

Terminal window
python -m scripts.login --service
access_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.

Terminal window
python -m scripts.login --refresh usr-sam
New 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.

Terminal window
cp .tokens/usr-sam.json old-sam.json
python -m scripts.login --refresh usr-sam
cp old-sam.json .tokens/usr-sam.json
python -m scripts.login --refresh usr-sam

The 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.

  • 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.

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?

Terminal window
python -m scripts.login --user usr-sam --reuse-code
Show 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:

  1. Use exactly the registered address in the application, or register the new one at the provider.
  2. 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.

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.

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).

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.
  • state protects the application against a callback it did not start; the nonce ties the ID token to this sign-in.
  • One sign-in gave three tokens. The ID token is for the application (aud ticket-cli), the access token for the API (aud ticket-api, 600 s), and the refresh token for the provider.
  • Three clients, three identities: help-desk-web (confidential), ticket-cli (public, PKCE only) and export-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.

Next: Read a token: JWT, signatures and keys