TT Lab
Get started
Learn Learning paths Courses

Sessions and Tokens — From the Browser to the Mesh

Keeping tokens out of the browser with a BFF

Continue in TT Lab

Goal

Bring up the issuer (8312), the downstream API (8311), and the BFF (8310) in one Pod, and prove with real HTTP requests that the access token never appears anywhere the browser can see and that only calls that go through the BFF work downstream.

Why it matters

If an SPA holds the token itself, a single compromised script running on that page lets the token leak out, and the attacker uses that token until it expires. The BFF of section 6.1 of RFC 10017 (OAuth 2.0 for Browser-Based Applications) keeps the token only in the server-side session and gives the browser only an HttpOnly session cookie. As a confidential client, the BFF exchanges the code for a token, and when passing downstream it strips the cookie and attaches the Bearer token. This structure collapses if even one place is loose — if the session check response carries the token, if downstream accepts a request with no token, or if logout does not revoke the token, the BFF is a BFF in name only. This lab makes you check each of those three gaps with requests.

Steps

  1. With the single file /root/st/bff/app.py, bring up the issuer (127.0.0.1:8312), the downstream API (127.0.0.1:8311), and the BFF (127.0.0.1:8310). All three return 200 and {"ok":true} for GET /health.
  2. Log in in the order GET /login, then the issuer POST /authorize, then the BFF /callback, and save the one Set-Cookie line that the callback handed down in /root/st/bff/cookie.txt. The cookie has the name __Host-Http-bff and has HttpOnly, Secure, SameSite=Strict, and Path=/.
  3. Write into /root/st/bff/exposure.txt whether bff:sess:<세션ID> in Redis has access_token (the placeholder is the session ID) and that value is nowhere in the headers or body of the responses the browser received.
  4. Call GET /bff/api/me with the cookie from logging in as bob, and write into /root/st/bff/proxy.txt whether the BFF attaches the Bearer and gets 200 and sub=bob downstream, and whether the cookie is not passed to downstream.
  5. Call downstream /me directly with no token, with only the session cookie, and with a bogus Bearer, and write into /root/st/bff/direct.txt whether all are 401 with WWW-Authenticate: Bearer.
  6. Write into /root/st/bff/csrf.txt whether POST /bff/api/notes is 201 only with a correct X-CSRF-Token and 403 if it is missing or wrong.
  7. After logging out with curl -X POST, write into /root/st/bff/logout.txt whether the BFF session is gone and the old access token is 401 downstream.
  8. Test the whole flow with /root/st/bff/e2e.sh and leave six lines in /root/st/bff/e2e.out.

Notes

Bring up the issuer, downstream API, and BFF

With the single file /root/st/bff/app.py, bring up the issuer (127.0.0.1:8312), the downstream API (127.0.0.1:8311), and the BFF (127.0.0.1:8310). All three return 200 and {"ok":true} for GET /health.

If you run three ThreadingHTTPServers as threads inside one process, bringing them up and down takes a single step. Start the server in the background and poll until all three ports respond.

What the browser receives is one session cookie

Log in in the order GET /login, then the issuer POST /authorize (form user=alice&pw=wonderland), then the BFF /callback, and save the one Set-Cookie line that the callback handed down in /root/st/bff/cookie.txt. The cookie name is __Host-Http-bff, it has HttpOnly, Secure, SameSite=Strict, and Path=/, and it has no Domain.

If you take the 302's Location with curl -w '%{redirect_url}' and pass it to the next request, you can walk through the flow like a browser. The request that sends the account to the issuer is a POST. Section 6.1.3.2 of RFC 10017 makes HttpOnly and Secure MUST.

The token exists only in the BFF session

After logging in, write into /root/st/bff/exposure.txt, as token_in_store=yes, token_in_body=no, and token_in_cookie=no, the result of checking that bff:sess:<세션ID> (JSON) in Redis has access_token (the placeholder is the session ID) and that value is nowhere in the headers or body of the callback response and the GET /bff/session response. GET /bff/session gives only auth, user, and csrf.

Pull the session ID from the cookie jar and the real token with redis-cli GET. Try to find that string in the response files with grep -F. The moment you put the token in the session check response for convenience, the BFF is a BFF in name only.

The BFF calls downstream with a Bearer attached

When you call GET /bff/api/me with the cookie from logging in as bob/builder, the BFF strips the cookie, attaches Authorization: Bearer <토큰> (the placeholder is the token), passes it to downstream GET /me, and gets 200 and {"sub":"bob"}. Downstream GET /inspect tells you through cookie_forwarded whether the cookie was passed over. Write the result into /root/st/bff/proxy.txt as me=200, sub=bob, and cookie_forwarded=False.

The BFF moves the path after /bff/api/ to downstream, and instead of copying the request headers wholesale, make Authorization fresh. Downstream must block a request with no token with a 401, so that a 200 through the BFF is itself evidence that the BFF attached the token.

Calling downstream directly is a 401

Skip the BFF and call downstream GET http://127.0.0.1:8311/me with no token, holding only the BFF session cookie, and with a bogus Bearer value, and write into /root/st/bff/direct.txt, as no_token=401, cookie_only=401, bogus_bearer=401, and www_authenticate=Bearer, whether all are 401 with WWW-Authenticate: Bearer attached. A POST /notes with no token must be a 401 too.

Downstream does not look at the cookie and looks only at the Authorization header. Decide whether the token is alive by asking the issuer's introspection. Section 3 of RFC 6750 defines the header to attach to a 401 response.

The BFF's writes are protected by a CSRF token

Only when you send the csrf value of GET /bff/session as the X-CSRF-Token header does POST /bff/api/notes (form text=...) pass to downstream and become 201, and write into /root/st/bff/csrf.txt, as no_header=403, wrong=403, and right=201, whether a missing or wrong header is 403 and is not recorded downstream.

Because the browser carries the cookie automatically, the cookie alone cannot be told apart from a forged request. Put the CSRF token into the session as well and compare with hmac.compare_digest. The rejection must happen before calling downstream.

Logout ends the session and the token together

After logging in, call POST /logout with curl -X POST (with the cookie and X-CSRF-Token), and write into /root/st/bff/logout.txt, as session_auth=False, store_key_gone=yes, and old_token_direct=401, whether GET /bff/session with the same cookie has auth False, bff:sess:<세션ID> in Redis has disappeared (the placeholder is the session ID), and calling downstream /me directly with the access token you pulled out before logout is 401.

If you delete only the session key, the token on the issuer side stays alive until it expires. At logout the BFF must first call the issuer's revocation endpoint (RFC 7009). If you leave out -X POST, curl sends a GET.

Prove the whole flow at once

With /root/st/bff/e2e.sh, test from login to logout and leave six lines in /root/st/bff/e2e.out: cookie_httponly=yes, token_exposed=no, via_bff=200, direct_no_token=401, csrf_missing=403, and after_logout_token=401. The grader also reruns e2e.sh to see whether the same result comes out.

Chain the earlier steps together in one script. If the script deletes the temporary files it made and produces its result only on standard output, it is safe to run again.