June 20, 20262 min read
A live now-playing widget under a strict CSP
The About section of this site shows what I'm listening to on Spotify, more or less in real time. It looks like a throwaway flourish. Getting it to behave under this site's security posture was the actual work.
The constraint that shaped everything
Two non-negotiables were already true before the widget existed:
- No secret ever reaches the browser. Spotify's API needs a client secret and a refresh token. Those are server-only, full stop.
- A strict, nonce-based Content-Security-Policy. Every request gets a fresh
nonce; only scripts carrying that nonce (and what they load, via
strict-dynamic) are allowed to run. There is no blanketunsafe-inlineescape hatch.
The first rule means the browser can't talk to Spotify directly. The second means I can't sprinkle in a quick inline script to paper over anything.
The server route does the secret-handling
The browser only ever talks to a same-origin route — /api/spotify/now-playing.
That route is where the refresh-token-for-access-token swap happens, cached in memory
so most requests skip it:
// refresh → access token, then fetch what's playing
const token = await getAccessToken(); // cached; refreshes on miss
const res = await fetch(NOW_PLAYING_ENDPOINT, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 204 || res.status > 400) {
return recentlyPlayedFallback(); // nothing live → "last played"
}
The client gets back a tiny, boring JSON shape: isPlaying, title, artist,
albumImageUrl, songUrl. Nothing sensitive, nothing to leak.
Degrade like the lights went out, not like the building burned down
The most important state is the one nobody notices. With the environment variables unset — a fresh clone, a preview deploy, a misconfiguration — the route returns a typed "not configured" payload instead of throwing. The widget reads that and shows a quiet, static version of itself. No console errors, no broken layout, no half-rendered card.
That mirrors how the contact form already handled a missing email key. Consistency in failure modes is underrated; it means one mental model covers the whole site.
What the CSP actually cost
Album art comes from Spotify's CDN, so i.scdn.co had to join img-src. That was the
entire CSP change. The widget itself is a client component that fetches the same-origin
route and polls every 30–60 seconds — no inline script, nothing for the nonce to bless,
nothing that fights strict-dynamic.
The lesson I keep relearning: if a feature needs you to weaken the CSP, the feature is usually wrong. Routing the data through your own origin is almost always cleaner than punching a hole in the policy.
Why bother
This site is a home base for a family of data projects, not a résumé. A live signal — proof that there's a person here, actively listening, actively building — does more for that premise than another paragraph about myself ever could. It just had to earn its place without compromising anything around it.
