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.

The fetch-pin-review-bump loop A clockwise cycle: fetch the upstream bundle, pin its SHA-384 digest into a checked-in manifest, review the resulting diff, then bump and deploy the new hashed file, after which the cycle repeats. 1. fetch upstream curl over TLS 2. pin the digest sha384 to manifest 3. review diff a human reads it 4. bump + deploy new hashed file vendor.manifest.json url + sha384 + file checked into git exact bytes digest moved approved repeat

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.

Scheduled vendor drift check A nightly CI job requests the bundle from the vendor CDN, compares the computed SHA-384 against the manifest, commits and opens a pull request when it differs, and a reviewer approves the merge so the new hashed file and integrity value deploy together. nightly CI job vendor CDN git repo reviewer GET bundle 200 + body compute sha384 vs manifest if digest differs commit + open PR request review approve, merge new hashed file + integrity ship together

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.

Anatomy of a vendored asset path The served path splits into a same-origin directory, the vendored bundle name, a truncated content hash that changes on every rebuild, and the extension, with the integrity and crossorigin attributes shown beneath. the URL your page requests /vendor/ analytics .9f3c1b8a4d21 .js same-origin path no vendor DNS vendored bundle reviewed in git content digest moves on rebuild immutable max-age 1 year integrity="sha384-..." crossorigin="anonymous"

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 crossorigin costs you nothing until the day it costs you everything. On a same-origin file the integrity attribute is honoured without it, so an omitted crossorigin="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.

Permalink to "Related"

Related Articles

Applying SRI to Google Tag Manager
Sandboxing Analytics Scripts with Iframes
Third-Party Tag & Analytics Integrity Asset Hashing & Dynamic Script…