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>
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);
}
}
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);
}
}
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");
}
Why this layered approach instead of just setting CSP in the controller?
-
Defense in depth. If someone adds a new embed endpoint and forgets to set framing headers, the filter's default
'self'prevents open framing. -
Single override point. The
String.replaceonCSP_FRAME_ANCESTORS_SELFensures we're modifying a known value, not blindly appending directives. -
No conflicts. One CSP header with one
frame-ancestorsdirective, 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();
});
}
}
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:
- Bundle size. Even a lightweight charting library adds 30-50KB. For an iframe that shows one tiny sparkline, that's absurd.
- Load time. The iframe should render instantly. No waiting for JavaScript bundles to download and parse.
-
CSP simplicity. No
script-srcdirectives 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();
}
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>
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>
);
}
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
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)