The usual SPA tutorial stores a token in localStorage and attaches a Bearer header. If you have a backend, the browser can hold nothing but an HttpOnly cookie — and a whole class of token theft disappears.
The default SPA auth tutorial has you store an access token (and usually a refresh token) in localStorage, attach it as a Bearer header, and hand-write refresh and expiry logic in the client. Every one of those is a liability: anything that achieves XSS can read localStorage and walk off with your tokens, and the refresh dance is fiddly to get right. The model I learned instead is one where the browser holds no token at all.
It's the Backend-for-Frontend (BFF) shape of OAuth Authorization Code. The backend is the confidential OAuth client; the SPA only ever does redirects.
/{provider}/callback?code&state route.code + state to the API. The backend performs the code-for-token exchange (PKCE, client secret, the whole thing — all server-side) and establishes a server-side session.Because there's no token in the client, the HTTP interceptor is almost nothing — send the cookie, and add a CSRF header on mutating requests:
@Injectable()
export class SessionInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler) {
let request = req.clone({ withCredentials: true }); // send the HttpOnly session cookie
const csrf = readCookie("csrf_token");
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
request = request.clone({ setHeaders: { "X-CSRF-Token": csrf } });
}
return next.handle(request);
}
}And the auth service is mostly redirects — it never sees a token:
startLogin(provider: string) {
const redirectUri = encodeURIComponent(`${location.origin}/${provider}/callback`);
this.http.get<{ uri: string }>(`${this.api}/auth/${provider}/login?redirect_uri=${redirectUri}`)
.subscribe(({ uri }) => (location.href = uri)); // full-page redirect to the provider
}
finishLogin(provider: string, code: string, state: string) {
// The SERVER exchanges code → tokens and sets the session cookie. Body is throwaway.
return this.http.get(`${this.api}/auth/${provider}/callback`,
{ params: { code, state }, responseType: "text" });
}
isAuthenticated() {
return this.http.get<{ authenticated: boolean }>(`${this.api}/auth/session`)
.pipe(map((r) => r.authenticated));
}A few things that surprised me building it:
true → false transition — pop a "session expired" notice and redirect to login. You don't need retry logic; you do need that probe, because a dead cookie otherwise surfaces as vague, scattered API errors.withCredentials: true is mandatory on every request. Miss it on one call and the cookie silently isn't sent — especially painful cross-origin in dev.:provider/callback), or the provider's redirect back lands on a 404.The honest security nuance: this isn't magic immunity. While the page is open, an XSS payload can still ride the cookie to make authenticated calls — so you still need a tight CSP and HttpOnly + SameSite on the cookie. What it removes is exfiltration: the attacker can't read a token out of the browser and replay it later from somewhere else, because there's no token to read. Given XSS-plus-stolen-refresh-token is the nightmare, closing that door is worth a lot.
The reframe: the reflex is "SPA equals hold a token and attach it." But if you already have a backend, the backend can be the confidential client and the browser can carry nothing but an opaque cookie it can't even read. Auth stops being client state you manage and becomes a redirect dance plus a single withCredentials flag — less code, and the most valuable thing to steal simply isn't there to steal.