OAUTH 2.0 · LAB
Move the browser client's tokens into a small backend for frontend
Run a local backend that is a confidential client, gives the page only an HttpOnly cookie, forwards to one fixed API and demands a custom header, then compare all three designs.
ReadyUses your lab tenant
The lesson
Builds on: Browser applications.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Register the backend as its own confidential client
Recorded as
tenant.oauth.clients.createsucceeded.Let the backend redeem Ava's code
Recorded as
oauth.tokensucceeded forlab-tmp-bffabout[email protected].Read Ava's profile through the backend
Recorded as
oidc.userinfosucceeded (userinfo_served) forlab-tmp-bff.
Setup
Choose Lab Photos as the lab tenant and press Start.
In Clients, create
lab-tmp-bfffrom the Web application preset: Confidential, PKCE Required, authorization code and refresh token, redirect URIhttp://127.0.0.1:8765/oauth/callback. In Access Token Management, rotate its secret.Put its credentials into the backend's environment, never into the page or a file:
export ISSUER="https://tenant-<id>.beyondthelogin.dev" BFF_ID="<lab-tmp-bff client ID>"
read -rs BFF_SECRET; export BFF_SECRET
mkdir -p ~/lab-bff && cd ~/lab-bff
Save
bff.mjs. It is about 60 lines, Node 18 or later, with no dependencies:
// The editor's backend for frontend: a confidential client that keeps tokens in server memory.
import http from 'node:http'; import crypto from 'node:crypto';
const { ISSUER, BFF_ID, BFF_SECRET } = process.env;
const ORIGIN = 'http://127.0.0.1:8765', REDIRECT = `${ORIGIN}/oauth/callback`;
const meta = await (await fetch(`${ISSUER}/.well-known/oauth-authorization-server`)).json();
if (meta.issuer !== ISSUER) throw new Error('metadata issuer does not match configuration');
const attempts = new Map(), sessions = new Map();
const random = () => crypto.randomBytes(32).toString('base64url');
const cookies = req => Object.fromEntries((req.headers.cookie ?? '').split(/;\s*/).filter(Boolean).map(c => c.split('=')));
const basic = 'Basic ' + Buffer.from(`${encodeURIComponent(BFF_ID)}:${encodeURIComponent(BFF_SECRET)}`).toString('base64');
const tokenRequest = async params => {
const r = await fetch(meta.token_endpoint, { method: 'POST', headers: { Authorization: basic }, body: new URLSearchParams(params) });
return r.ok ? r.json() : null;
};
const app = `fetch('/api/me', { headers: { 'Editor-Request': '1' } }).then(r => r.text()).then(t => { document.getElementById('out').textContent = t; });`;
http.createServer(async (req, res) => {
const url = new URL(req.url, ORIGIN), jar = cookies(req);
const send = (status, body, headers = {}) => { res.writeHead(status, headers); res.end(body); };
console.log(req.method, url.pathname);
if (url.pathname === '/login') {
const id = random(), state = random(), verifier = random();
attempts.set(id, { state, verifier, issuer: ISSUER, until: Date.now() + 600_000 });
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return send(302, '', { 'Set-Cookie': `attempt=${id}; Path=/oauth/callback; HttpOnly; SameSite=Lax; Max-Age=600`,
Location: `${meta.authorization_endpoint}?` + new URLSearchParams({ response_type: 'code', client_id: BFF_ID, redirect_uri: REDIRECT,
scope: 'openid photos.read offline_access', state, code_challenge: challenge, code_challenge_method: 'S256' }) });
}
if (url.pathname === '/oauth/callback') {
const attempt = attempts.get(jar.attempt); attempts.delete(jar.attempt);
const p = url.searchParams;
if (!attempt || attempt.until < Date.now() || p.get('state') !== attempt.state || p.get('iss') !== attempt.issuer) return send(400, 'This response does not match a sign-in started here.');
const tokens = await tokenRequest({ grant_type: 'authorization_code', code: p.get('code') ?? '', redirect_uri: REDIRECT, code_verifier: attempt.verifier });
if (!tokens) return send(502, 'The code exchange was refused.');
const sid = random(); sessions.set(sid, tokens);
// A page rather than a redirect, so the next navigation starts on this site and carries the Strict cookie.
return send(200, '<!doctype html><meta http-equiv="refresh" content="0;url=/">Signed in.', { 'Content-Type': 'text/html',
'Set-Cookie': [`editor-session=${sid}; Path=/; HttpOnly; SameSite=Strict`, 'attempt=; Path=/oauth/callback; Max-Age=0'] });
}
if (url.pathname === '/api/me') {
if (req.headers['editor-request'] !== '1') return send(403, 'Missing Editor-Request header');
const sid = jar['editor-session'], tokens = sessions.get(sid);
if (!tokens) return send(401, 'No session');
// One fixed destination from metadata, never an address taken from the request.
const call = t => fetch(meta.userinfo_endpoint, { headers: { Authorization: `Bearer ${t.access_token}` } });
let r = await call(tokens);
if (r.status === 401 && tokens.refresh_token) {
const fresh = await tokenRequest({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token });
if (fresh) { sessions.set(sid, { ...tokens, ...fresh }); r = await call(fresh); }
}
return send(r.status, await r.text(), { 'Content-Type': 'application/json' });
}
if (url.pathname === '/app.js') return send(200, app, { 'Content-Type': 'text/javascript' });
if (url.pathname === '/') return send(200, `<!doctype html><meta charset="utf-8"><title>Editor</title><a href="/login">Open from photo account</a>
<p>${sessions.has(jar['editor-session']) ? 'Session cookie arrived with this page.' : 'No session cookie arrived with this page.'}</p>
<pre id="out"></pre><script src="/app.js"></script>`, { 'Content-Type': 'text/html' });
send(404, 'Not found');
}).listen(8765, '127.0.0.1', () => console.log(`editor backend on ${ORIGIN}`));
Start it:
node bff.mjs. In production the cookie names would carry the__Host-prefix, which browsers accept only over HTTPS.
Walkthrough
Open
http://127.0.0.1:8765/, select Open from photo account and sign in as Ava. The page reports that the session cookie arrived and shows Ava's UserInfo, fetched through/api/me.
Why it matters: from the tenant's point of view, the editor now looks like the printer's website: a confidential client on a server that authenticates at the token endpoint.
Look for tokens in the browser. In developer tools, Application: nothing in local or session storage. In the console,
document.cookiedoes not showeditor-session, because it is HttpOnly.
Why it matters: the browser holds only a cookie that means nothing outside this backend.
In Network, look for a token request: there is none. The backend's terminal shows the callback and
/api/merequests it handled.
Why it matters: tokens travel only between the backend, the authorization server and the API, and never through the page.
Call the backend from the console without the custom header:
fetch('/api/me').then(r => r.status)returns403. With{ headers: { 'Editor-Request': '1' } }it returns200.
Why it matters: a cross-site form cannot add a custom header. Combined with SameSite=Strict, a forged request from another site arrives without the cookie, the header, or both.
Try from another origin. Open
http://localhost:8765/(a different origin on the same server) and in its console run:
fetch('http://127.0.0.1:8765/api/me', { headers: { 'Editor-Request': '1' }, credentials: 'include' }).then(r => r.status, e => 'blocked: ' + e.message)
The browser first sends an OPTIONS preflight, which the backend's terminal shows answered with 404 and no CORS approval, and then refuses to send the real request.
Why it matters: a script on another origin that adds the header triggers a preflight, and a backend that approves no other origin never receives the request.
Compare the three designs from what you saw in this lab and the previous one. Fill in, for each design, what reached the browser and what injected script could carry away:
| Design | Reached the browser | Script could carry away |
|---|---|---|
| Browser-only client (previous lab) | ||
| Token-mediating backend | ||
| Backend for frontend (this lab) |
Why it matters: injected script in this page could still call /api/me while the tab is open, with the editor's access. It could not take anything that keeps working after the tab closes.
Break it
See why the callback answers with a page. In bff.mjs, replace the callback's final send(200, ...) with send(302, '', { Location: '/', 'Set-Cookie': [...] }) keeping the same cookies, restart, and sign in again. The first page load now says no session cookie arrived: the redirect continues a navigation that started on the tenant's site, so the browser withholds the Strict cookie. Reload, and it arrives.
Restore: put the navigation page back and restart bff.mjs.
Check your work
Press Check my progress before Cleanup, because Cleanup deletes the client. The checks look for, in order: the tenant.oauth.clients.create for lab-tmp-bff, its code exchange for Ava, and oidc.userinfo through it. Audit shows lab-tmp-bff as the authenticated actor of the code exchange.
Cleanup
Stop
bff.mjsand delete the folder:rm -rf ~/lab-bff. Rununset BFF_SECRET.Delete the client
lab-tmp-bff.