Browser applications
The photo editor at editor.example is built differently. It is a set of files served from a static host, and once your browser loads them, the editor runs entirely in the page, with no server of its own behind it.
It still needs your photos. It asks for photos.read to open them and albums.create to save edited copies into a new album. With no backend, the only place left for its OAuth client is the JavaScript in your browser, registered with the photo service as the public client photo-editor.
The client in the page
The editor runs the authorization code flow with PKCE in JavaScript. When you select Open from photo account, it generates a fresh verifier with the browser's cryptographic random number generator and sends the tab to the photo service's authorization endpoint. The page is replaced while you are away, so the editor keeps its pending attempt, including state and the verifier, in the tab's session storage until its callback page reads and removes them.
There is no client secret. Anyone can read the editor's JavaScript, so a secret placed there would prove nothing. PKCE binds the code to this attempt, and the photo service requires it from public clients.
The code exchange meets a rule of the browser. A page may not read responses from a different origin, meaning a different combination of scheme, host, and port, unless that server allows it. This is the same-origin policy, and a server relaxes it with Cross-Origin Resource Sharing (CORS) by naming the origins whose scripts may read its responses. The editor's code exchange shows both sides of that arrangement. All domains, tokens, and keys in these examples are fictional.
POST /token HTTP/1.1
Host: auth.photos.example
Origin: https://editor.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=demo-code-21
&redirect_uri=https%3A%2F%2Feditor.example%2Foauth%2Fcallback
&code_verifier=btl_training_verifier_for_the_photo_editor_2026
&client_id=photo-editor
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://editor.example
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "demo-editor-access-token-1",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "demo-editor-refresh-token-1",
"scope": "photos.read albums.create"
}
The browser adds the Origin header itself, and the page cannot change it. The Access-Control-Allow-Origin header in the response is what lets the editor's script read the tokens. Some authorization servers allow any origin at the token endpoint, others only origins registered for the client, and the photo API has to allow editor.example as well. The authorization endpoint is different. The editor sends the whole tab there instead of fetching it, and current guidance says that endpoint must not allow cross-origin reads at all.
Where the tokens live
The editor now holds its tokens in the same environment as everything else on the page. What happens if someone else's script runs there?
Suppose a bug in the editor inserts photo captions into the page as HTML instead of plain text, and one caption contains a fragment of script. Or suppose a library the editor loads from another site is compromised. Either way, the attacker's code runs inside the editor's page with the same powers as the editor's own code. This is cross-site scripting, often shortened to XSS. Whatever the editor can do with its tokens, the injected script can do too, and often it can read them outright.
| Where the editor keeps tokens | How long they last | What injected script can do |
|---|---|---|
| A variable in the page's memory | Until the tab reloads or closes | Harder to find directly, but it can replace the functions that use the token, or simply call them. |
| Session storage | Until the tab closes, and only in that tab | Read the tokens with one line of code. |
| Local storage | Until removed, shared by every tab of the origin | Read the tokens with one line of code, in any editor tab, long after you finished editing. |
| A web worker or service worker | While the worker runs | It cannot read the worker's variables, but it can ask the worker for anything the page may ask for, such as a fresh access token. |
Writing a token into a cookie from JavaScript adds a problem of its own: the browser then also sends it with every request to the editor's own host, where it was never meant to go.
Isolation helps. A refresh token kept inside a worker cannot simply be copied out, and an access token held in memory disappears with the tab. But no storage choice stops the most capable version of the attack. Injected script can start an authorization request of its own, in a hidden frame or a small pop-up window. If you are still signed in at the photo service and have approved the editor before, the photo service may answer without showing you anything. It has no reason to hesitate, because the code can only be delivered to the editor's registered HTTPS callback. But that callback is on the editor's origin, so the script can read the code there and redeem it with the client ID and a verifier it generated itself. It leaves with a complete set of tokens of its own, however carefully the editor hid the existing ones.
Refreshing without a backend
The editor's access tokens last ten minutes, and a long editing session needs new ones without sending you back through the photo service each time.
Browser applications used to manage this with a hidden frame that loaded the authorization endpoint. The photo service recognized your sign-in from its own session cookie and returned a new code without anything appearing on screen. Inside a frame on editor.example, that cookie from auth.photos.example is a third-party cookie. Several browsers now block or partition third-party cookies by default, and people can block them in others. In those browsers the photo service sees a frame with no sign-in, and silent renewal fails. A pop-up window is a top-level page rather than a frame, so it still carries the photo service's cookie, which is why the injected script's own authorization request keeps working there.
Refresh tokens have largely taken over the job, which puts a long-lived credential where injected script can reach it. Current guidance for browser applications therefore sets firm rules for refresh tokens issued to them:
- As for any public client, the authorization server must rotate them or bind them to a key that the browser holds, as the lesson on the refresh token grant described.
- They must stop working after a maximum lifetime, or after a period without use.
- When the first refresh token has a fixed expiry, the replacements issued by rotation must not outlast it.
The photo service gives the editor ten-minute access tokens and refresh tokens that end eight hours after you connect. The next morning, the editor sends you through the authorization endpoint again, which is quick if you are still signed in at the photo service. The fixed limit matters because rotation notices theft only when both the stolen and the legitimate copies are used.
Defending the page
The first defense against injected script is keeping it out: inserting untrusted text such as captions as text rather than markup, and choosing carefully which outside scripts the editor loads. A Content Security Policy adds a second layer. It is a response header that tells the browser which scripts the page may run and which addresses its scripts may contact:
Content-Security-Policy: script-src 'self'; connect-src 'self' https://auth.photos.example https://api.photos.example
With this policy, the browser runs script only from the editor's own origin, so a fragment smuggled into the page as markup does nothing, and it stops the page's scripts from sending requests anywhere else, which leaves stolen data fewer ways out. It is defense in depth, not a cure. It cannot help once the attacker's code arrives from an allowed source, and it does not stop that code from using the editor's tokens while the page is open.
Narrow scopes and short access token lifetimes limit the rest. Even so, a browser-only client keeps working tokens in the page. The IETF's best current practice for browser applications, RFC 10017, recommends against this design for business applications, sensitive applications, and applications that handle personal data, and a photo library is personal data. The next lesson moves the editor's tokens out of the browser by giving it a small backend of its own, without changing what you see in the page.