DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Embedding Stats Widgets Without Breaking CSP: iframes, CORS, and Content Security Policy

This article was originally published on Jo4 Blog.

I wanted to let users embed their link stats on their own websites. Should be simple — render some numbers in an iframe, done. Right?

Fourteen hundred lines of code later, I had a working embed system with layered CSP control, server-side sparklines, and per-entity framing permissions.

Here's everything I learned.


TL;DR

Embeddable widgets need layered framing control: a default CSP via a security filter, then surgical per-response overrides in the controller. Public embeds get frame-ancestors *; disabled or missing ones get frame-ancestors 'none' plus X-Frame-Options: DENY. Compute expensive visuals (like sparklines) server-side to keep the iframe lightweight.


What We're Building

A stats widget that users can drop into their site with a single iframe tag:

<iframe
  src="https://jo4.io/embed/stats/abc123?days=30"
  width="400"
  height="200"
  frameborder="0"
></iframe>
Enter fullscreen mode Exit fullscreen mode

It shows click counts, a sparkline chart, and basic analytics for a shortened URL. The owner controls whether the embed is public or disabled via a toggle in their dashboard.

Step 1: The Backend — EmbedController

The controller is the brains of the operation. It handles the request, checks permissions, sets security headers, and returns the stats data.

Here's the core structure (simplified from 232 lines):

@RestController
@RequestMapping("/embed")
@RequiredArgsConstructor
public class EmbedController {

    private final UrlService urlService;
    private final EmbedStatsCacheService embedStatsCacheService;

    private static final String CSP_FRAME_ANCESTORS_SELF = "frame-ancestors 'self'";

    @GetMapping("/stats/{shortCode}")
    public ResponseEntity<EmbedStatsResponse> getStats(
            @PathVariable String shortCode,
            @RequestParam(defaultValue = "30") String daysParam,
            HttpServletResponse response) {

        int days = parseDaysSafely(daysParam);
        UrlEntity url = urlService.findByShortCode(shortCode);

        if (url == null || !url.isEnablePublicStats()) {
            setDenyFraming(response);
            return ResponseEntity.notFound().build();
        }

        setAllowFraming(response);

        EmbedStatsResponse stats = embedStatsCacheService
            .getStats(shortCode, days);

        return ResponseEntity.ok(stats);
    }
}
Enter fullscreen mode Exit fullscreen mode

The key methods are setAllowFraming and setDenyFraming. This is where the layered CSP control happens.

Step 2: Layered Framing Control

Here's the architecture for controlling who can iframe your content:

Layer 1: Security filter sets a safe default. SecurityHeadersConfig adds frame-ancestors 'self' to all /embed/ paths. This means by default, embeds can only be framed by your own domain.

@Component
public class SecurityHeadersConfig implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletRequest request = (HttpServletRequest) req;

        if (request.getRequestURI().startsWith("/embed/")) {
            response.setHeader("Content-Security-Policy",
                "frame-ancestors 'self'");
        }

        chain.doFilter(req, res);
    }
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Controller surgically replaces the directive. After the filter runs, the controller overrides the CSP based on the entity's permissions:

private void setAllowFraming(HttpServletResponse response) {
    // Replace the default 'self' with wildcard — allow anyone to embed
    String currentCsp = response.getHeader("Content-Security-Policy");
    if (currentCsp != null) {
        response.setHeader("Content-Security-Policy",
            currentCsp.replace(CSP_FRAME_ANCESTORS_SELF, "frame-ancestors *"));
    }
    // Remove X-Frame-Options entirely — it conflicts with frame-ancestors
    response.setHeader("X-Frame-Options", "");
}

private void setDenyFraming(HttpServletResponse response) {
    // Replace with 'none' — block all framing
    String currentCsp = response.getHeader("Content-Security-Policy");
    if (currentCsp != null) {
        response.setHeader("Content-Security-Policy",
            currentCsp.replace(CSP_FRAME_ANCESTORS_SELF, "frame-ancestors 'none'"));
    }
    // Belt and suspenders — also set X-Frame-Options for older browsers
    response.setHeader("X-Frame-Options", "DENY");
}
Enter fullscreen mode Exit fullscreen mode

Why this layered approach instead of just setting CSP in the controller?

  1. Defense in depth. If someone adds a new embed endpoint and forgets to set framing headers, the filter's default 'self' prevents open framing.
  2. Single override point. The String.replace on CSP_FRAME_ANCESTORS_SELF ensures we're modifying a known value, not blindly appending directives.
  3. No conflicts. One CSP header with one frame-ancestors directive, not multiple headers fighting each other.

Step 3: Caching with EmbedStatsCacheService

Stats queries are expensive — they aggregate click data over time ranges. You don't want to run that query every time someone loads a page with an embedded widget.

@Service
@RequiredArgsConstructor
public class EmbedStatsCacheService {

    private final StatsRepository statsRepository;
    private final Cache<String, EmbedStatsResponse> cache;

    public EmbedStatsResponse getStats(String shortCode, int days) {
        String cacheKey = shortCode + ":" + days;

        return cache.get(cacheKey, key -> {
            List<DailyClickCount> clicks = statsRepository
                .getDailyClicks(shortCode, days);

            return EmbedStatsResponse.builder()
                .totalClicks(clicks.stream()
                    .mapToLong(DailyClickCount::getCount).sum())
                .sparklineSvgPoints(computeSparkline(clicks))
                .days(days)
                .build();
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The cache key is shortCode:days, so different time ranges are cached independently. The cache TTL is short enough (5 minutes) that stats stay reasonably fresh without hammering the database.

Step 4: Server-Side Sparklines

Here's one I'm proud of. The sparkline chart in the embed widget is computed entirely server-side as an SVG points string.

Why not use a JavaScript charting library in the iframe? Three reasons:

  1. Bundle size. Even a lightweight charting library adds 30-50KB. For an iframe that shows one tiny sparkline, that's absurd.
  2. Load time. The iframe should render instantly. No waiting for JavaScript bundles to download and parse.
  3. CSP simplicity. No script-src directives to worry about in the iframe's CSP.

The server computes the SVG polyline points:

private String computeSparkline(List<DailyClickCount> clicks) {
    if (clicks.isEmpty()) return "";

    long maxCount = clicks.stream()
        .mapToLong(DailyClickCount::getCount)
        .max().orElse(1);

    double width = 200.0;
    double height = 40.0;

    StringBuilder points = new StringBuilder();
    for (int i = 0; i < clicks.size(); i++) {
        double x = (i * width) / (clicks.size() - 1);
        double y = height - (clicks.get(i).getCount() * height / maxCount);
        if (i > 0) points.append(" ");
        points.append(String.format("%.1f,%.1f", x, y));
    }

    return points.toString();
}
Enter fullscreen mode Exit fullscreen mode

The frontend renders it with a single <polyline> element:

<svg viewBox="0 0 200 40" class="sparkline">
  <polyline
    points="${sparklineSvgPoints}"
    fill="none"
    stroke="#3b82f6"
    stroke-width="1.5"
  />
</svg>
Enter fullscreen mode Exit fullscreen mode

No JavaScript. No charting library. Just an SVG string from the server and a few bytes of HTML.

Step 5: Frontend — Shared StatsWidget

On the React side, I originally had two separate components: BioStatsWidget.tsx and UrlStatsWidget.tsx. They were 80% identical. Classic copy-paste debt.

I refactored both into a single shared StatsWidget.tsx:

interface StatsWidgetProps {
  shortCode: string;
  days?: number;
  variant: 'bio' | 'url';
}

export function StatsWidget({ shortCode, days = 30, variant }: StatsWidgetProps) {
  const { data, isLoading } = useEmbedStats(shortCode, days);

  return (
    <div className={`stats-widget stats-widget--${variant}`}>
      <div className="stats-widget__count">
        {isLoading ? '...' : data?.totalClicks.toLocaleString()}
      </div>
      {data?.sparklineSvgPoints && (
        <svg viewBox="0 0 200 40" className="stats-widget__sparkline">
          <polyline
            points={data.sparklineSvgPoints}
            fill="none"
            stroke="currentColor"
            strokeWidth="1.5"
          />
        </svg>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The variant prop controls styling differences between bio page stats and URL stats. Same data, same logic, different visual treatment.

The Full Picture

Here's how all the pieces fit together:

User's website
  └── <iframe src="jo4.io/embed/stats/abc123">
        │
        ├── SecurityHeadersConfig: sets frame-ancestors 'self' (default)
        ├── EmbedController: overrides to frame-ancestors * (public embed)
        ├── EmbedStatsCacheService: returns cached stats
        ├── Sparkline: server-computed SVG points
        └── Response: JSON + correct CSP headers
Enter fullscreen mode Exit fullscreen mode

The iframe loads fast (no JS charting library), respects CSP (no inline scripts), and has granular framing control (per-entity, not global).

Common Pitfalls

Setting both CSP frame-ancestors and X-Frame-Options with conflicting values. Modern browsers prefer frame-ancestors, but older ones fall back to X-Frame-Options. Make sure they agree — or remove X-Frame-Options when you want permissive framing.

Forgetting that CSP applies to the framed page, not the framing page. The frame-ancestors directive goes on the response from jo4.io/embed/stats/*, not on the parent page. This is the embed's way of saying "these domains are allowed to frame me."

Caching responses with per-entity headers. If you put a CDN in front of your embed endpoint, make sure the cache key includes the entity's framing permission. Otherwise, a cached public-embed response might get served for a disabled embed, or vice versa.


Have you built embeddable widgets before? What was the trickiest part — CSP, styling, or something else entirely?

Building jo4.io - a URL shortener with embeddable stats widgets that actually respect your security policy.

Top comments (0)