
An embeddable badge is one of the oldest growth tricks on the web. You give people a small image that shows a number they are proud of, they paste it on their site, and the wrapping link points back to you. Shields.io did it for build status. Product Hunt did it for upvotes. The mechanics look trivial: serve an image, wrap it in an anchor, done. I rebuilt one recently. The badge shows a website's live Domain Rating, the 0 to 100 backlink-strength score from Ahrefs , and updates itself. The plan was an afternoon. It turned into a three-bug postmortem, and every bug failed silently, which is the worst kind. Here is what actually went wrong, in the order it bit me, so you can skip the pain. The design, and the one rule that drives all of it The badge is an SVG generated per domain and served from a route like /api/dr-badge/example.com.svg. The wrapping snippet is plain HTML: <a href="https://saascity.io" target="_blank" rel="noopener"> <img src="https://example.com/api/dr-badge/example.com.svg" width="210" height="56"> </a> The anchor lives in the embedder's HTML, so it is a real link, not an iframe. That part matters for SEO, because a link inside an iframe passes no equity. The single rule that shapes everything else: the image endpoint must never call the upstream API on the hot path. Every visitor to every embedder's site loads that image. If each load triggered an Ahrefs request, one popular embed would rate-limit my whole integration in minutes. So the score lives in a small cache table, the API is called only at generation time and during a lazy 7-day refresh, and an unknown domain returns a neutral fallback image with zero upstream calls. Cheap, and impossible to weaponize against my own quota. The general pattern of "your data, their page" is the same idea this HackerNoon piece on CORS circles, except here the resource is an image, not a fetch. Bug 1: the table that returned permission denied First live test: generate a badge, read it back. The generator said the score was 95. The badge showed the fallback. No error in the app, no 500, nothing. The cache reads and writes were wrapped in try/catch that swallowed failures and degraded to the fallback, which is correct behavior for a public image but terrible for debugging. I added a raw probe and got the truth: permission denied for table dr_cache (code 42501) The table had Row Level Security enabled with no policies, the standard Supabase pattern for a service-role-only table. The trap: the service role bypasses RLS, but it still needs table-level privileges. Without a grant, even the admin client is denied, and the error code is 42501, a grant error, not an RLS denial. RLS denial returns empty rows, not an exception. The fix was one line in the migration: grant select, insert, update on table public.dr_cache to service_role; Lesson: if you create tables outside the dashboard, grant explicitly. A swallowed-error data layer will hide this until you go looking. Bug 2: The header that blocks every embed With the cache fixed, the badge rendered when I opened its URL directly. Embedded on another origin as an img, it broke again. Same-origin worked; cross-origin did not. My instinct said CORS. That was wrong, and the distinction is worth burning into memory: an img tag does not use CORS. Browsers load images cross-origin by default. CORS only governs fetch and canvas pixel reads. So CORS was a dead end. The real culprit was Cross-Origin-Resource-Policy. My site set a global Cross-Origin-Resource-Policy: same-origin header as a hardening default, applied to every route. CORP same-origin does exactly what it says: it forbids other origins from loading the resource, including as an image. The badge, the one asset that exists to be loaded cross-origin, was being blocked by a blanket security header. The fix is to scope the loosening to the badge route only and leave the rest of the site locked down: Cross-Origin-Resource-Policy: cross-origin Access-Control-Allow-Origin: * The CORP line is the one that fixes the img embed. The Access-Control-Allow-Origin line is not required for an image at all; it is insurance for the rare consumer that reads the badge through fetch or a canvas. If you serve anything meant for third-party embedding, audit your global headers first. A sensible site-wide default is precisely what kills the one endpoint that needs to be open. The MDN reference on CORP spells out the three values and why same-origin is the wrong one for public assets. The SVG-in-img trap, and a CSP belt Two more things bite specifically when an SVG is loaded through img rather than inlined. First, the SVG renders in a restricted mode. No scripts run, no external resources load, and crucially, no web fonts load. If your badge references a custom font, it falls back to something ugly on the embedder's site. The fix is to use a system font stack inside the SVG and accept that the type will look slightly different per OS. For a 56-pixel badge, nobody notices. Second, an SVG can be opened as a top-level document, where it does run in active mode and could execute script. The only dynamic value in my badge is the domain, which is already allowlisted to host characters and HTML-escaped, so injection is not possible. Still, defense in depth is cheap, so the response carries a tight policy: Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline' That neutralizes the entire class of SVG-document XSS without affecting how the badge renders as an image. Bug 3: the test that lied I wrote a Playwright test to prove the cross-origin load worked. It failed even after the CORP fix. The image reported complete = true but naturalWidth = 0, the classic signature of an image that errored. The cause was not the badge. My test loaded the host page from about:blank and later from a 127.0.0.1 page hitting a localhost image. Both tripped Chrome's Private Network Access protection, which blocks a page treated as public from loading a resource on a local address. The badge was fine. The harness was generating a false negative. The fix was to make the test faithful: a real second local HTTP server on its own port, serving a plain page that embeds the badge. Local-to-local, no PNA, no synthetic-origin quirk. Once the host page was a real server rather than a fulfilled stub, the test passed and matched production behavior. If your cross-origin test uses about: blank or a mocked response, it is testing the mock, not the browser. The payoff None of these bugs were in the badge logic. They were in a Postgres grant, a global response header, an SVG rendering mode, and a test environment. Each failed quietly, which is why they cost hours instead of minutes. The order of debugging that worked: surface the swallowed error first, then check response headers against what the consuming context actually requires, then make the test match reality. The reason any of this is worth doing is distribution. A badge that shows a real number of people want to display is a backlink that compounds, which is the durable end of the strategy laid out in how PageRank and backlinks still work in 2026. I packaged the finished version into a free Domain Rating badge generator so anyone can grab one without rebuilding the plumbing. If you are shipping your own embeddable widget, copy the constraint, not just the code: never touch your upstream API on the path that every visitor triggers, and check your security headers before you blame the browser. \ \
View original source — Hacker Noon ↗


