Self-Hosting Third-Party Scripts
Permalink to "Self-Hosting Third-Party Scripts"Part of Third-Party Tag & Analytics Integrity, this page shows how to vendor a vendor-hosted bundle into your own origin so it becomes an ordinary build artifact that you can hash, pin, review and cache like any file you wrote yourself.
Quick Reference
Permalink to "Quick Reference"| Element | Value | Notes |
|---|---|---|
| Fetch | curl -fsSL --proto '=https' |
Non-zero exit on HTTP error, refuses a plaintext redirect |
| SRI digest | openssl dgst -sha384 -binary | openssl base64 -A |
Raw bytes, base64, unwrapped |
| Filename digest | openssl dgst -sha256 -r truncated to 12 hex chars |
Hex is path-safe; base64 is not |
| Served path | /vendor/<name>.<hex>.js |
New digest means a new URL |
| Caching | Cache-Control: public, max-age=31536000, immutable |
Safe only because the path changes on every content change |
| Tag attributes | integrity="sha384-…" plus crossorigin="anonymous" |
Both, always |
| Drift check | Scheduled CI job, re-fetch and compare, open a pull request | Never auto-merge |
The loop is small: fetch, pin, review, bump. Everything else on this page is plumbing around those four verbs.
The mental model
Permalink to "The mental model"A third-party tag is a dependency you never declared, never pinned and never reviewed. The URL in the tag is a promise that the vendor will keep serving something reasonable at that address, and nothing enforces it: the bytes can change between two page loads, differ by geography, or differ per visitor if the vendor generates the bundle per request. That last case is why an integrity attribute on a live vendor URL so often fails within a week — there is nothing stable to pin. Vendoring dissolves the problem by moving the bytes rather than trying to constrain them. Once the file lives in your repository and is served from your origin, it is no longer a moving target; it is a build artifact with a commit history, a reviewer and a digest that only changes when a human decides it should.
The cycle has four steps and one durable record. Fetch pulls the current upstream file to a temporary path. Pin computes two digests from those exact bytes — a SHA-384 base64 digest for the integrity attribute and a short hex digest for the filename — and writes them into a manifest that is checked into version control. Review is a human reading the diff of the vendored file in a pull request, which is the only step that actually inspects what the vendor changed. Bump merges and deploys, at which point the new hashed filename and the new integrity value ship together in the same commit. The manifest is the single source of truth between runs: it is what a scheduled job compares against to decide whether anything moved, and what your templates read to render the tag.
Decide what to vendor before you start automating it. A 300 KB analytics runtime that phones home to a per-account endpoint is a poor candidate; a small, stable widget or a charting library published on a public CDN is an excellent one. Ranking your tags first, as described in Scoring Third-Party Script Risk, keeps you from spending review effort on the tags that would break the fastest.
Canonical example: the vendoring script
Permalink to "Canonical example: the vendoring script"One script does the fetch and the pin. It writes the file under a content-hashed name, updates the manifest, and prints the tag it just made valid. Run it locally the first time, then let CI run it on a schedule.
#!/usr/bin/env bash
# scripts/vendor-third-party.sh <name> <url>
set -euo pipefail
NAME="${1:?usage: vendor-third-party.sh <name> <url>}"
URL="${2:?usage: vendor-third-party.sh <name> <url>}"
OUT_DIR="static/vendor"
MANIFEST="vendor.manifest.json"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
# -f fails on any HTTP error; --proto '=https' refuses a plaintext redirect.
curl -fsSL --proto '=https' --tlsv1.2 -o "$tmp" "$URL"
# Digest for the integrity attribute: raw SHA-384 bytes, base64, unwrapped.
sri="sha384-$(openssl dgst -sha384 -binary "$tmp" | openssl base64 -A)"
# Digest for the filename: hex is safe in a URL path, base64 is not.
short="$(openssl dgst -sha256 -r "$tmp" | cut -c1-12)"
file="$NAME.$short.js"
mkdir -p "$OUT_DIR"
# Drop the previous copy so the pull request reads as a replacement.
rm -f "$OUT_DIR/$NAME".*.js
cp "$tmp" "$OUT_DIR/$file"
[ -f "$MANIFEST" ] || echo '{}' > "$MANIFEST"
jq --arg n "$NAME" --arg url "$URL" --arg f "$file" --arg i "$sri" \
'.[$n] = { url: $url, file: $f, integrity: $i }' \
"$MANIFEST" > "$MANIFEST.tmp"
mv "$MANIFEST.tmp" "$MANIFEST"
printf '<script src="/vendor/%s" integrity="%s" crossorigin="anonymous" defer></script>\n' \
"$file" "$sri"
Two details matter more than they look. The manifest records only fields derived from the content — no fetch timestamp, no run id — because any per-run field would produce a diff on every execution and a pull request that says nothing. And the two digests serve different jobs: the base64 SHA-384 goes in the attribute because that is what the specification defines, while the truncated hex goes in the path because base64 contains +, / and =. If the digest commands need more explanation, Generating SRI Hashes with OpenSSL and shasum covers the encoding rules and the common mistakes.
Your template reads the manifest and renders the tag, so the hash and the filename can never drift apart:
<script src="/vendor/analytics.9f3c1b8a4d21.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
defer></script>
Variants
Permalink to "Variants"Automate the fetch in CI and open a pull request
Permalink to "Automate the fetch in CI and open a pull request"The point of the scheduled job is not to update anything automatically — it is to notice. It re-runs the vendoring script; if the upstream bytes are unchanged the working tree stays clean and the job ends silently. If they moved, the new file and the new manifest entry become a pull request that a person must read.
# .github/workflows/vendor-drift.yml
name: vendor-drift
on:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
jobs:
refresh:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Re-vendor the upstream bundle
run: ./scripts/vendor-third-party.sh analytics https://cdn.example-vendor.com/v3/analytics.js
- name: Open a pull request when the digest moved
uses: peter-evans/create-pull-request@v6
with:
branch: vendor/analytics-refresh
title: 'chore(vendor): upstream analytics.js digest changed'
body: |
The upstream bundle no longer matches the pinned SHA-384.
Read the diff under static/vendor/ before merging.
add-paths: |
static/vendor
vendor.manifest.json
create-pull-request is a no-op on a clean tree, so the schedule costs nothing on the days the vendor ships nothing. Give the branch a fixed name: repeated changes then update the same pull request instead of opening a new one every night. Do not enable auto-merge on it. The review is the control; an auto-merged vendor refresh is exactly the unreviewed remote dependency you just spent effort removing, with extra steps.
Serve it with a content-hashed filename and immutable caching
Permalink to "Serve it with a content-hashed filename and immutable caching"Because the filename contains a digest of the bytes, the URL is a permanent identifier: a change to the content produces a different URL, so the cached copy of the old URL can never be wrong. That is what licenses a one-year cache lifetime.
location ^~ /vendor/ {
root /srv/www/static;
add_header Cache-Control "public, max-age=31536000, immutable" always;
# Only needed if /vendor/ is served from a different origin than the page:
# add_header Access-Control-Allow-Origin "https://www.example.com" always;
}
immutable (RFC 8246) tells the browser not to revalidate even on a manual reload; Firefox and Safari honour it, and Chrome ignores it without harm because it already serves fresh responses without a conditional request. The commented CORS header matters only if you park /vendor/ on a static subdomain — at that point the fetch becomes cross-origin, crossorigin="anonymous" stops being optional, and a missing Access-Control-Allow-Origin blocks the script outright rather than merely failing the hash check. How CORS and crossorigin Affect SRI works through that interaction in detail.
Proxy instead of copying when the bytes must stay live
Permalink to "Proxy instead of copying when the bytes must stay live"Some tags cannot be frozen — a payments SDK that must match the processor’s server-side version, for instance. A same-origin reverse proxy under /vendor/ still removes the third-party DNS lookup, the third-party TLS handshake and the cookie surface, but it does not give you a stable digest, because you are still serving whatever the upstream returns right now. Treat it as a network-level improvement only, and keep the tag without an integrity attribute rather than shipping one that will break. If you need the file present locally as a safety net rather than as the primary source, Serving Local Fallback Bundles covers the fallback shape instead.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
The licence may forbid it. Plenty of commercial tags are licensed for delivery from the vendor’s own edge, and rehosting the bundle breaches the terms even though nothing technically stops you. Read the agreement before the first
curl, and check the bundle for an embedded licence header. Open-source libraries usually just require you to preserve the notice; SaaS SDKs frequently require written permission. -
The script may hardcode its own origin. A vendored bundle that computes its endpoint from a constant, or from
document.currentScript.src, will either keep calling the vendor CDN for its real payload or break when the path it expects has changed. Load the page with the network panel filtered to the vendor domain: if requests remain, you have pinned a loader and the interesting code is still unpinned. -
Self-updating tags defeat the pin by design. Tag managers and feature-flag SDKs are built to change their behaviour without a deploy, and vendoring the container script does not freeze the configuration it downloads. You gain a pinned loader and nothing else. Recognising that boundary early saves you from claiming a guarantee you do not have — Failing CI on SRI Hash Drift is the right control for the parts you genuinely can pin.
-
Cookies and origin allowlists move with the script. A tag that reads or writes a cookie on the vendor’s domain loses it once the request comes from your origin, and vendors that validate a referring origin in their dashboard will reject traffic until you register the new one. Session stitching, consent state and authenticated widgets are the usual casualties. Test in a browser with third-party cookies blocked, since that is the failure mode most of your users already see.
-
Dropping
crossorigincosts you nothing until the day it costs you everything. On a same-origin file theintegrityattribute is honoured without it, so an omittedcrossorigin="anonymous"looks fine in every test. Move that file to a static subdomain later and the fetch becomes cross-origin: the response is opaque, the digest cannot be computed, and the browser blocks the script. Ship both attributes from the first commit.
Verification Steps
Permalink to "Verification Steps"1. Confirm the served bytes match the pinned digest
Permalink to "1. Confirm the served bytes match the pinned digest"curl -fsSL https://www.example.com/vendor/analytics.9f3c1b8a4d21.js \
| openssl dgst -sha384 -binary | openssl base64 -A; echo
The base64 string must equal the value after sha384- in the tag. A mismatch means a build step, a minifier or an edge transform is rewriting the file after it was hashed.
2. Confirm the caching headers
Permalink to "2. Confirm the caching headers"curl -sSI https://www.example.com/vendor/analytics.9f3c1b8a4d21.js | grep -i '^cache-control'
Expected output:
cache-control: public, max-age=31536000, immutable
3. Confirm the browser is enforcing the attribute
Permalink to "3. Confirm the browser is enforcing the attribute"Change one character of the integrity value in a local build and reload. Chrome logs, and blocks the script:
Failed to find a valid digest in the 'integrity' attribute for resource
'https://www.example.com/vendor/analytics.9f3c1b8a4d21.js' with computed
SHA-384 integrity '...'. The resource has been blocked.
If the script still runs, the attribute is not reaching the rendered HTML — check that the template is reading the manifest rather than a stale hardcoded tag.
4. Confirm no traffic still goes to the vendor
Permalink to "4. Confirm no traffic still goes to the vendor"grep -r 'cdn.example-vendor.com' dist/ || echo 'no upstream references in build output'
Then load a real page and filter the network panel by the vendor domain. Zero requests means the vendoring is complete; anything else is the loader problem described in the gotchas.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Does self-hosting a third-party script actually reduce risk?
It converts a mutable remote dependency into an immutable reviewed one. The vendor can no longer change the code your users run without a commit in your repository, and the asset becomes hashable. What it does not do is make the code safe: the vendored bundle still runs with full access to the page. Vendoring buys you change control and a stable digest, not isolation.
Can a script served from my own origin carry an integrity attribute?
Yes. Subresource Integrity applies to same-origin and cross-origin subresources alike. The CORS requirement only exists for cross-origin fetches, where the response must be readable for the digest to be checked. On a same-origin script the integrity attribute is honoured with or without crossorigin, and adding crossorigin=“anonymous” is harmless.
How often should the vendor refresh job run?
Daily is a reasonable default for an actively maintained tag, weekly for a stable one. The schedule sets your worst-case patch latency: if the vendor ships a security fix an hour after your job runs, you carry the old code until the next run. Add a manual trigger so an engineer can force a refresh when the vendor announces an urgent fix.
What if the vendor's terms of service forbid rehosting?
Then do not vendor it. Many analytics and tag-management products license delivery from their own edge and treat a copy on your origin as a breach, sometimes voiding support. Ask for written permission, which larger vendors often grant for enterprise plans, or contain the script another way, such as running it in a sandboxed frame instead of the main document.
Why does my vendored bundle still make requests to the vendor domain?
Because you vendored a loader, not the payload. Many tags ship a small stub that reads its own script element or a hardcoded constant and then fetches the real code, configuration or a per-account container from the vendor’s CDN at runtime. Load the page with the network panel filtered by the vendor domain; any remaining request tells you the pinned digest covers only the stub.
Related
Permalink to "Related"- Applying SRI to Google Tag Manager — what you can and cannot pin when the tag is designed to update itself
- Sandboxing Analytics Scripts with Iframes — the containment option for scripts you are not allowed to copy
- Detecting Changes in Third-Party Scripts — monitoring upstream bundles you have chosen to keep loading remotely