Skip to content

Session lifecycle

  • Lesson 9
  • 55 min
  • Module 3: Sessions and application integration

What you will be able to do

Decide when a browser session ends, and test each ending: idle, absolute, refresh refused, sign-out and a second person at the same browser. A session that ends too late keeps access that someone lost. A sign-out that ends too little hands the previous person’s data to the next one.

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 Browser session choices: python -m pytest prints 193 passed.
  • The practice provider (python -m idp) and the API (fastapi dev) running, each in its own terminal.
  • A third terminal in the project folder, with the virtual environment active, for curl and psql.

Tested on macOS (Apple silicon) with Chromium 156; the Windows commands are written but not tested. Some steps change times in the sessions table by hand, so that you do not wait 30 minutes or 8 hours. Those steps are labelled as constructed.

A public library has a few computers for everyone. You sit down, sign in to your email, and read for a while. Then a friend calls, and you leave for twenty minutes. Some library computers sign you out after a quiet period, and all of them sign everyone out at closing time. Those are two clocks: one for silence, one for the whole day.

There is a third trap. Before you go, you close your email and click “Sign out”. The next visitor opens the same email website and clicks “Sign in”. The website does not ask for a password: it signs them in as you. Your mail program signed you out, but the account service behind it still remembered you. The next person now reads your mail.

So “signed out” has more than one meaning. To end a session completely, you must know every place that remembers you.

The problem: a session that ends too late, or not completely

Section titled “The problem: a session that ends too late, or not completely”

The ticket API’s sessions from the last lesson last up to 8 hours. Three questions show the problem.

  • Sam leaves the help desk open and goes home at noon. The cookie works until evening. Anyone at Sam’s desk can use it.
  • Larkfield disables Omar’s account at the identity provider. The API keeps its own session for Omar. How long does Omar’s open browser keep working?
  • Sam signs out, and Ines sits down at the same desk. She clicks “Sign in”. Who does the API think she is?

The third question has a measured answer, and it is the wrong one. The author signed Sam in, ended his session at the API only, and then signed in “as Ines” in the same browser. The provider showed no page at all, and /v1/me answered "user_id": "usr-sam". The same happened in Chromium:

The help desk page with the status line: Signed in as usr-sam. The session ends at 2026-10-10T14:32:49 at the latest, or after 30 minutes without a request. The buttons Switch account and Sign out are shown.
Constructed: Sam's session was ended at the API only. Then the next person clicked Sign in, and the provider signed Sam in again without showing a page.

This lesson gives each question an answer that the code enforces and a test proves.

A session needs three clocks, and each one answers a different risk.

  • Idle timeout: the session ends after a time without any request. This is the library’s quiet-period rule. It protects the forgotten tab. The project uses 30 minutes (SESSION_IDLE_MINUTES).
  • Absolute timeout: the session ends a fixed time after the sign-in, whatever happens. This is closing time. It forces a fresh sign-in every working day. The project uses 8 hours (SESSION_MAX_HOURS).
  • Access time: the access token inside the session lasts 10 minutes. After that, the API asks the provider again with the refresh token. If the provider says no, the session ends. This is how a decision at the provider reaches the API within minutes.

Each ending writes its reason into the session’s row, in end_reason: idle_timeout, absolute_timeout, provider_refused, or logout for a sign-out. The next request with that cookie gets 401 session_ended, with the reason in the message, and the API deletes the cookie. Here is the whole life of a session:

A state diagram with four states. Signed out, no cookie. GET /auth/login leads to Sign-in started: the state, nonce and verifier are kept for 10 minutes, one use. A dashed arrow back to Signed out: a bad state gives 400 login_failed. The callback OK leads to Active: cookie set, access time 10 minutes, idle 30 minutes, absolute 8 hours. Active has a loop to itself: a request, or a refresh that the provider allows. Four endings lead from Active to Ended: logout, idle_timeout, absolute_timeout, provider_refused. Ended means end_reason is set and the cookie is deleted. A dashed arrow from Ended back to Signed out: the next request gets 401 session_ended.
The session lifecycle as the API enforces it. Every arrow is a test in tests/test_sessions.py, and every end reason is a value of sessions.end_reason.
Text description of the diagram

Start on the left. A sign-in first waits in login_requests for at most 10 minutes, and its state works once. A good callback makes the session active. While it is active, each request moves the idle clock, and a refresh renews the access time. Four arrows leave the active state, one per end reason. An ended session never becomes active again: the only way back is a new sign-in, with a new cookie.

Here is one constructed morning, with the rules of the code. Sam signs in at 09:00. Three clocks start: access until 09:10, idle end at 09:30, absolute end at 17:00.

Time Sam’s request What the API does Access until Idle end
09:00 signs in new session, cookie set 09:10 09:30
09:04 GET /v1/me 200; the idle clock moves 09:10 09:34
09:25 GET /v1/me access time over: refresh at the provider; 200 09:35 09:55
10:10 GET /v1/me 45 minutes without a request: 401 session_ended (idle_timeout) — —
A timeline from 09:00 to 10:20 with four rows. Sam's requests: green marks at 09:00 (sign in), 09:04 and 09:25, and a red mark at 10:10 with 401 idle_timeout. Access time, 10 minutes: a box until 09:10, then a dashed box over (no request), then a green box refreshed from 09:25 to 09:35; the server used the refresh token at 09:25. Idle limit, 30 minutes after the last request: three blue lines that end at 09:30, 09:34 and 09:55, and a red box at 10:10: no request for 45 minutes. Absolute limit, 8 hours after sign-in: an arrow to the right, ends at 17:00 whatever happens.
Constructed: the three clocks of one session. The idle end moves with every request; the access time is renewed only when a request finds it over; the absolute end never moves.
Text description of the diagram

Read the rows from top to bottom. Between 09:10 and 09:25, the access time is over, but nothing happens: the API refreshes only when a request needs it. At 09:25, it refreshes before it answers. The idle line starts again at every request, so its end moves from 09:30 to 09:34 to 09:55. At 10:10, the idle check runs first, before any refresh, so the session ends without a call to the provider.

Notice the order at 10:10. The API checks the absolute end first, then the idle end, and only then the access time. An ended session never reaches the provider.

Grace, Larkfield’s owner, asks for proof, not promises: “Show me each way a session ends.” The tests already do it. Run them first:

Terminal window
python -m pytest tests/test_sessions.py -v
tests/test_sessions.py::test_sign_in_sets_an_httponly_cookie_and_the_api_knows_the_person PASSED
tests/test_sessions.py::test_the_server_keeps_only_a_hash_of_the_cookie_and_an_encrypted_refresh_token PASSED
tests/test_sessions.py::test_a_callback_with_an_unknown_or_used_state_fails PASSED
tests/test_sessions.py::test_return_to_cannot_send_you_to_another_site PASSED
tests/test_sessions.py::test_a_change_with_the_cookie_needs_the_csrf_token PASSED
tests/test_sessions.py::test_logout_ends_the_session_and_revokes_the_refresh_token PASSED
tests/test_sessions.py::test_a_session_ends_when_idle_or_too_old[last_seen_at = now() - interval '31 minutes'-idle_timeout] PASSED
tests/test_sessions.py::test_a_session_ends_when_idle_or_too_old[expires_at = now() - interval '1 second'-absolute_timeout] PASSED
tests/test_sessions.py::test_the_session_asks_the_provider_again_when_the_access_token_is_old PASSED
tests/test_sessions.py::test_a_disabled_account_loses_the_session_at_the_next_refresh PASSED
tests/test_sessions.py::test_account_switching_starts_a_new_session_for_the_other_person PASSED
tests/test_sessions.py::test_the_page_and_the_session_answer_are_not_cached PASSED

The tests move times in the database instead of waiting. Now do the same by hand, against the running API, so that you see each answer.

  1. In the third terminal, define two small helpers. signin follows the sign-in like the browser, with the cookie jar jar.txt. It opens the provider’s page first; only if the page asks, it picks the person. me calls /v1/me with the jar:

    Terminal window
    signin() {
    TO=$(curl -s -o /dev/null -w '%{redirect_url}' -b jar.txt -c jar.txt "http://127.0.0.1:8000/auth/login?return_to=/app/$2")
    BACK=$(curl -s -o /dev/null -w '%{redirect_url}' -b jar.txt -c jar.txt "$TO") # open the provider's page
    # no redirect: the page asks for a person, so pick one
    [ -z "$BACK" ] && BACK=$(curl -s -o /dev/null -w '%{redirect_url}' -b jar.txt -c jar.txt -d user="$1" "$TO")
    curl -s -o /dev/null -b jar.txt -c jar.txt "$BACK"
    curl -s -b jar.txt http://127.0.0.1:8000/auth/session; echo
    }
    me() { curl -s -b jar.txt http://127.0.0.1:8000/v1/me; echo; }

    Start with a fresh browser: delete jar.txt (rm -f jar.txt, or Remove-Item jar.txt in PowerShell). Then run signin usr-sam. The answer starts with {"signed_in":true,"user_id":"usr-sam".

  2. Refresh (constructed). Move Sam’s access time one minute into the past, then call the API:

    Terminal window
    docker compose exec db psql -U tickets -c "UPDATE sessions SET access_expires_at = now() - interval '1 minute' WHERE user_id = 'usr-sam' AND ended_at IS NULL;"
    me
    docker compose exec db psql -U tickets -c "SELECT user_id, access_expires_at - now() AS access_left FROM sessions WHERE user_id = 'usr-sam' AND ended_at IS NULL;"
    UPDATE 1
    {"user_id":"usr-sam","kind":"user","name":"Sam", … }
    user_id | access_left
    ---------+-----------------
    usr-sam | 00:09:59.687126
    (1 row)

    /v1/me answered 200, and the access time is 10 minutes again. The API used the refresh token at the provider before it answered. The provider rotated the refresh token too: the row now keeps the new one, encrypted.

  3. Disabled at the provider. Sign in as Omar, then disable him at the provider:

    Terminal window
    rm -f jar.txt
    signin usr-omar
    python -m idp disable usr-omar
    me
    usr-omar is disabled: they cannot sign in or refresh tokens. Tokens already issued stay valid until they expire.
    {"user_id":"usr-omar", … }

    Omar still gets 200. His session’s access time is not over, so the API does not ask the provider yet. Now move his access time back (constructed), as in step 2, and call again:

    Terminal window
    docker compose exec db psql -U tickets -c "UPDATE sessions SET access_expires_at = now() - interval '1 second' WHERE user_id = 'usr-omar' AND ended_at IS NULL;"
    me
    python -m idp enable usr-omar
    {"error":{"code":"session_ended","message":"Your session has ended (provider_refused). Sign in again.", … }}
    usr-omar is enabled: they can sign in.

    The provider refused the refresh, so the session ended. Without the constructed step, this happens at most 10 minutes after the account is disabled: the access time is the limit of stale access.

  4. Idle and absolute (constructed). Sign in as Camille, move their last request 31 minutes back, and call:

    Terminal window
    rm -f jar.txt
    signin usr-camille
    docker compose exec db psql -U tickets -c "UPDATE sessions SET last_seen_at = now() - interval '31 minutes' WHERE user_id = 'usr-camille' AND ended_at IS NULL;"
    me
    {"error":{"code":"session_ended","message":"Your session has ended (idle_timeout). Sign in again.", … }}

    Do it again, from signin usr-camille, with expires_at = now() - interval '1 second' instead of the last_seen_at change. The message says (absolute_timeout).

  5. Sign-out, and a copy of the old cookie. Sign Sam in, keep a copy of the jar, and sign out with the CSRF token. Then use the copy:

    Terminal window
    rm -f jar.txt
    signin usr-sam
    cp jar.txt sams-jar.txt
    CSRF=$(curl -s -b jar.txt http://127.0.0.1:8000/auth/session | python -c "import sys, json; print(json.load(sys.stdin)['csrf_token'])")
    curl -s -X POST -b jar.txt -c jar.txt -H "X-CSRF-Token: $CSRF" http://127.0.0.1:8000/auth/logout; echo
    curl -s -b sams-jar.txt http://127.0.0.1:8000/v1/me; echo
    {"signed_out":true,"provider_logout_url":"http://localhost:8400/logout?post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Fapp%2F"}
    {"error":{"code":"session_ended","message":"Your session has ended (logout). Sign in again.", … }}

    Deleting the cookie in the browser is not enough, and the API does not rely on it. The row says logout, so a copied cookie is dead too. The API also revoked the refresh token at the provider.

Count the endings in your database:

Terminal window
docker compose exec db psql -U tickets -c "SELECT end_reason, count(*) FROM sessions GROUP BY 1 ORDER BY 1;"

The author’s run of the same steps gave one absolute_timeout, one idle_timeout, three logout, one provider_refused, and one session still active (end_reason empty). Your counts depend on how often you tried.

Account switching: the sign-in that nobody saw

Section titled “Account switching: the sign-in that nobody saw”

Now step 5 has a second half. Sam signed out at the API. Ines sits down and runs signin usr-ines with the same jar, as the next person at the same browser would:

Terminal window
signin usr-ines
{"signed_in":true,"user_id":"usr-sam", … }

The answer says usr-sam. The provider redirected at once, so the helper never reached the step that picks usr-ines. This is the library trap, and the diagram shows why:

A sequence with three participants: the browser at the shared desk, ticket-api and the identity provider. Sam is signed in; the API set ticket_session and the provider set idp_session. The browser sends POST /auth/logout for Sam's session; the API answers 200 and deletes ticket_session. A note says that idp_session is still there: the provider still knows Sam. Ines sits down and clicks Sign in. The browser sends GET /auth/login; the API answers 302 to /authorize. The browser sends GET /authorize with idp_session. The provider answers 302 straight back with a code for Sam, with no page, so Ines never sees a choice. The browser sends GET /auth/callback with the code, and the API answers 302 /app/ with a new session for usr-sam. The page says Signed in as usr-sam. A green box shows the fix: Switch account adds prompt=select_account, and Sign out also opens the provider's logout URL. With prompt=select_account, the browser sends GET /authorize and the provider answers 200 with the sign-in page, where Ines picks Ines.
Two sessions, two cookies. Signing out of the API ends only the API's session; the provider's own session signs the previous person in again, silently.
Text description of the diagram

Follow the two cookies. ticket_session belongs to the API, and the sign-out deleted it. idp_session belongs to the provider, on another site, and the API cannot delete it. When the next sign-in reaches the provider, the provider sees its own valid session for Sam. It does what single sign-on is for: it signs him in without asking. Real providers do the same.

There are two fixes, and the page at /app/ uses both.

  • Sign out ends the API’s session and then sends the browser to the provider_logout_url from the answer. That is the provider’s end-session address (RP-initiated logout). The provider ends its own session and sends the browser back to /app/.
  • Switch account ends the API’s session and starts a new sign-in with prompt=select_account. That parameter asks the provider to show its page even when it remembers someone. Sam’s session at the provider may continue; that is his choice on his device.

Try the second fix with the helper. Its second argument adds text to the sign-in address:

Terminal window
CSRF=$(curl -s -b jar.txt http://127.0.0.1:8000/auth/session | python -c "import sys, json; print(json.load(sys.stdin)['csrf_token'])")
curl -s -X POST -b jar.txt -c jar.txt -H "X-CSRF-Token: $CSRF" http://127.0.0.1:8000/auth/logout > /dev/null
signin usr-ines "&prompt=select_account"
{"signed_in":true,"user_id":"usr-ines", … }

The provider showed its page, and the pick reached it. In the author’s Chromium run, Switch account did the same: the page showed Signed in as usr-ines, and nothing of Sam’s. After Sign out, the provider’s logout sent the browser back to /app/, which said “Not signed in.” The Back button showed “Not signed in.” too.

What the browser keeps of the previous person

Section titled “What the browser keeps of the previous person”

Switching accounts must not leave Sam’s data on Ines’s screen. The project stops it in three places:

  • The page keeps data only in memory. clearData() in app.html empties it at sign-out, before anything else happens.
  • Every authenticated answer says Cache-Control: no-store: /v1/me, /auth/session, /app/ and, from Module 4, every ticket. The browser and any proxy keep no copy, so Back and the history cannot show Sam’s answers to Ines.
  • Each sign-in gets a new session and a new cookie. Sam’s old cookie stays dead (401 session_ended), even if someone kept it.

So far, the idle and absolute endings were constructed. Make one real. Stop fastapi dev with Ctrl+C, and start it again with a one-minute idle limit:

Terminal window
SESSION_IDLE_MINUTES=1 fastapi dev

Sign in with signin usr-grace, call me, wait 70 seconds, and call me again. Predict both answers before you run them. Then start the API again without the setting (in PowerShell, run Remove-Item Env:SESSION_IDLE_MINUTES first).

Show the answer

The first me answers 200 with Grace’s memberships, and it moves her idle end to one minute later. After 70 seconds without a request, the second me answers:

{"error":{"code":"session_ended","message":"Your session has ended (idle_timeout). Sign in again.", … }}

The access time (10 minutes) was not over. The idle check comes first, so the API did not call the provider.

When to choose which limits, and what they cost

Section titled “When to choose which limits, and what they cost”

The three numbers are choices, not facts of nature. Choose them with the people who use the page.

  • Idle 30 minutes suits a help desk, where people step away often. A shorter idle limit protects shared desks better, but people sign in more often, and they may find a way around it.
  • Absolute 8 hours is one working day. It also limits a stolen cookie: it works for one day at most, not for weeks.
  • Access time 10 minutes bounds stale access from the provider: a disabled account ends within 10 minutes. Shorter means more calls to the provider; each active session refreshes about six times an hour.

The costs: a database write on every request (the idle clock), a call to the provider every 10 minutes per active session, and people who must sign in again. The provider’s limits matter too: its refresh token here lasts 8 hours, so the API cannot refresh beyond that anyway.

A removed membership is not a provider decision: Larkfield removes Camille in its own table. From Module 4, the API reads the membership on every request, so their access ends at their next request. Lifecycle changes measures that path.

Common mistake

Everyone is signed out a few minutes after each restart

Symptom: after you restart the API, or after fastapi dev reloads because you saved a file, people stay signed in for a few minutes. Then every one of them gets 401 session_ended (provider_refused), although nobody was disabled. The API’s log printed SESSION_KEY is not set: sessions end when the API stops at start-up.

Cause: SESSION_KEY is missing from .env, so the API makes a new random key at each start. The refresh tokens in the sessions table were encrypted with the old key. The sessions continue until their access time is over. Then the API cannot decrypt the refresh token, so it cannot refresh, and it ends the session.

Diagnose: python -m scripts.check_identity, from the next lesson, prints PROBLEM SESSION_KEY is set with the hint “Without it, every restart of the API signs everyone out.” In the database, the ended sessions have end_reason = 'provider_refused' within 10 minutes of the restart.

Fix:

  1. Check .env for a line SESSION_KEY=…. python -m idp init writes it; if you made .env again later, it is missing.
  2. Run python -m idp init --force to write new secrets. It also makes a new signing key and new client secrets, so restart the provider and the API afterwards.
  3. Sign in again. Sessions now survive a restart of the API.

On a server, keep SESSION_KEY in the secret store, as the API key in the Docker course. If it leaks, change it: every session then ends at its next refresh, which is the safe result.

Check your understanding

Why does a session need both an idle timeout and an absolute timeout? Give a case that each one catches and the other does not.

Show the answer

The idle timeout catches a forgotten session. Sam leaves the desk at noon, and the session ends 30 minutes later, although the 8 hours are not over. The absolute timeout catches a session that never goes quiet. A page that polls every minute, or a stolen cookie in constant use, never trips the idle clock. It still ends 8 hours after the sign-in. The absolute end also forces a fresh sign-in, so the provider checks the person and their second factor again.

Check your understanding

Predict: Larkfield disables Omar at the provider 2 minutes after his last refresh. He keeps working in his open browser. When does his session end, with which reason, and why not at once?

Show the answer

About 8 minutes later, at his first request after the 10-minute access time is over. Then the API tries to refresh, the provider refuses, and the session ends with provider_refused: 401 session_ended. It does not end at once, because the API does not ask the provider on every request. The access time is the agreed limit of stale access. If 10 minutes is too long, shorten the access time. Or let the provider tell the API at once (back-channel logout, not built in this course).

Check your understanding

Completion check: Sam is signed in at a shared desk. Show, with commands or in the browser, that Ines can take over without seeing anything of Sam's, and that Sam's old session cannot be used again.

Show the answer

Use Switch account (or sign out, then signin usr-ines "&prompt=select_account"). Evidence: the provider shows its page, and /auth/session answers "user_id":"usr-ines". The page shows only Ines’s /v1/me, because clearData() emptied it. The answers say Cache-Control: no-store, so Back shows nothing of Sam’s. A copy of Sam’s cookie gets 401 session_ended (logout). Then show the failure that this prevents. After an API-only sign-out without prompt=select_account, the same signin usr-ines answers usr-sam.

Recap

  • A session has three clocks: idle (30 min), absolute (8 h) and access time (10 min, renewed with the refresh token).
  • It has four endings, each kept in sessions.end_reason: logout, idle_timeout, absolute_timeout, provider_refused. The next request gets 401 session_ended and loses the cookie.
  • A disabled account at the provider loses its session at the next refresh, so within 10 minutes; a copied cookie after sign-out is dead.
  • Signing out of the API does not sign out of the provider. Without care, the next “Sign in” silently signs the previous person in again. Fix it with the provider’s logout URL and with prompt=select_account for Switch account.
  • No data of the previous person stays: memory cleared at sign-out, Cache-Control: no-store on every personal answer, and a new cookie per sign-in.

The next lesson looks at the other side of all these redirects: what the provider must know about the API, and what changes with a real provider.

Next: Provider configuration