cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

Third-party scripts and supply chain

Every script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.

Code you did not write, running as you

Picture the checkout page of an online store. The team wrote the cart and the forms, reviews every pull request, and runs a strict CSP (Content Security Policy, the header that limits which scripts may run). Yet the page also loads an analytics library, a tag manager, an A/B testing tool, a chat widget, a fraud check, a session replay tool, and an ad pixel: seven companies whose code nobody on the team reviewed.

Then count the code inside the team's own bundle. A typical React app installs a few dozen packages from npm, which pull in hundreds or thousands of transitive dependencies (packages your packages depend on). Strangers wrote each one, and any of them can publish a new version tomorrow.

This is the supply chain of a frontend: all the code that reaches the user's browser without being written by you. The HTTP Archive's Web Almanac finds third-party requests on more than nine in ten pages it crawls.

Same origin, same power

A script tag does not run the vendor's code in the vendor's world. It runs it in yours. When you write script src="https://cdn.vendor.com/chat.js", the browser runs that file in your page with your origin (the scheme, host, and port of your page, such as https://shop.example). Once it runs, where the file came from no longer matters.

So the chat widget can do anything your own code can do:

  • Read and change the DOM, including the card number field as the user types.
  • Read cookies that are not HttpOnly, plus localStorage, sessionStorage, and IndexedDB for your origin.
  • Send requests as the user to your own API, with the user's cookies attached, because the requests come from your origin.
  • Send data anywhere with fetch, an image URL, or a WebSocket, unless your CSP limits where the page may connect.
  • Load more scripts, which get the same power. One tag can become ten.
A zone labelled your page at https://shop.example holds three scripts side by side: your app code, analytics.js, and chat.js from a vendor. Arrows lead from the zone to four things every script in it can reach: the DOM and forms including the card field, cookies unless they are HttpOnly, your API with the user's cookies, and any URL through fetch or beacons. A note says HttpOnly cookies are the one thing no script can read.your page: https://shop.exampleyour app codeanalytics.jschat.jssame origin, same powerDOM, formscard field toocookiesunless HttpOnlyyour APIwith user cookiesany URLfetch, beaconsonly HttpOnly cookies are out of every script's reach
The browser does not remember where a script came from once it runs. The vendor's chat widget gets exactly the same reach as your own code.

Two kinds of risk, and two kinds of cost

The risk has two shapes. In a compromised vendor, someone breaks into the company or CDN that serves a script and changes the file, so every site that loads it runs new code after its own deploy. In a compromised package, someone publishes a bad npm version, and it is built into your next deploy.

Even honest third-party code has a cost. It downloads bytes, opens connections, and uses the main thread, which hurts INP (Interaction to Next Paint, how long the page takes to show a response to a tap or key press). It often collects data about your users that you did not mean to share. This lesson covers attacks, defenses, speed, and privacy.

Check your understanding · Exercise 1

A marketing chat widget is loaded with a plain script tag from the vendor's CDN onto https://shop.example. What can the widget's code do? Select all that apply.

The mental model: a chain of people holding your keys

Think of your page as a house, and every piece of code that runs in it as a person holding a full set of keys. You hand keys to your own team, to each vendor whose script you load, and to each package author in your lockfile. Most of them are honest and careful. But a thief only needs one of them to lose their keys.

Worse, the people holding keys hand out copies. The chat vendor's script is built from its own npm packages and served from a CDN account that someone at the vendor logs into. Each link is a place where a change can slip in, and you see none of them.

A tree of trust. Your page trusts your bundle and a vendor tag. Your bundle trusts react and date-lib, and date-lib trusts tiny-dep 1.0.4, marked as the bad link. The vendor tag trusts the vendor's build, which trusts the vendor's own dependencies, and a CDN account, which trusts whoever owns the domain. The bundle side runs from your domain; the vendor side can change after you deploy.your pageyour bundlevendor tagreactdate-libvendor buildCDN accounttiny-dep 1.0.4vendor's depsdomain ownerbuilt into your filescan change after you deploy
You review the top of the tree. An attacker only needs one box anywhere below it, and the boxes on the right can change without any deploy on your side.

Where you place trust, link by link

A file in the browser is only as trustworthy as every step that produced and delivered it. For a vendor tag:

  1. The vendor's developers and their dependencies. Their build pulls in packages, like yours.
  2. The vendor's build and release system. A CI server with a secret that can publish files.
  3. The hosting account and domain. Whoever controls cdn.vendor.com decides what that URL returns. Domains can be sold, expire, or be taken over.
  4. The URL you wrote. A versioned URL (/lib@4.4.1/lib.min.js) points at one file. A versionless URL (/latest/tag.js) points at whatever the vendor serves today.
  5. Your page. The browser runs whatever arrives, unless you told it what to expect.

For an npm package, the last steps run on your side. Your installer picks a version, downloads it from the registry, may run its install scripts on your laptop or CI server, and your bundler copies its code into your own files. It is then served from your domain and looks first-party, but it is not.

Three questions for every link

Every defense in this lesson answers one of three questions:

QuestionWhat it meansTools that answer it
Is it the code I expect?The bytes that run are the bytes you reviewed or pinned.SRI, lockfiles with hashes, npm ci, provenance, self-hosting
What can it reach if it goes bad?Limit the blast radius: the damage a bad version can do.Sandboxed iframes, workers, CSP, HttpOnly cookies, hosted payment fields
Do we need it at all?Fewer links means fewer chances to fail.Inventory, owners, reviews, budgets, removing dead tags

No single tool answers all three. SRI proves a file did not change, not that the file you pinned was safe. A sandbox limits damage but cannot tell you the code is good. Removing a tag removes its risk entirely, but only for that tag, so real defense stacks all three.

Think first

Your bundle is served from https://app.example, your own domain. A package inside it turns out to be malicious. Does serving it from your own domain help?

How real attacks happened

Supply-chain attacks are not theory. Each well-known case broke a different link in the chain, so read them as a map of entry points.

A timeline from 2017 to 2025 with six incidents alternating above and below the axis: crossenv in 2017, a typo-named package; event-stream in 2018, a new maintainer; British Airways in 2018, an edited file on the payment page; ua-parser-js in 2021, a stolen npm login; Polyfill.io in 2024, a sold domain; and chalk and debug in 2025, a phished maintainer.crossenv2017, typo nameevent-stream2018, new ownerBritish Airways2018, edited fileua-parser-js2021, stolen loginPolyfill.io2024, sold domainchalk, debug2025, phishedone broken link reaches every site downstream
Packages on npm and scripts hosted on servers both get attacked, and each case broke a different link: a name, an owner, a server, a login, a domain.

event-stream, 2018: a friendly new maintainer

event-stream was a popular Node.js streaming package with about two million downloads a week. Its original author no longer used it, so when a stranger offered to maintain it, he handed over publishing rights. The new maintainer added a dependency, flatmap-stream, whose code carried an encrypted payload.

The payload decrypted only inside the build of one app, the Copay Bitcoin wallet, where it tried to steal wallet keys. Everyone else got harmless code, so it went unnoticed for weeks. The lesson: a package can be safe in your app and dangerous in another, and a change of maintainer is a real risk signal.

British Airways, 2018: one edited library on the payment page

Attackers got into British Airways' systems and edited a copy of the Modernizr library that its website served. They added about 20 lines that copied card details from the payment form and sent them to a look-alike domain. The skimmer ran for about two weeks in August and September 2018.

Hundreds of thousands of customers were affected, and in 2020 the UK's data regulator fined the airline £20 million. A skimmer injected into checkout JavaScript is often called Magecart, after the groups that made it common; Ticketmaster UK was skimmed the same year through a chatbot vendor's script. The lessons: watch payment pages for script changes, and keep card fields out of your page.

ua-parser-js, 2021: a hijacked account

ua-parser-js parses user agent strings and had millions of weekly downloads. In October 2021 an attacker took over the maintainer's npm account and published three versions whose install script dropped a cryptominer and, on Windows, a password stealer. They were live for a few hours.

Anyone who installed fresh in those hours, without a lockfile or with a range that allowed the new version, got the malware on their machine or CI server. Teams with a committed lockfile and npm ci were safe until they chose to update.

Polyfill.io, 2024: the domain changed hands

cdn.polyfill.io served browser polyfills, picked per visitor from the User-Agent header, to hundreds of thousands of sites. In February 2024 the domain and project were sold to a new company, and the original author warned people to stop using it. In June 2024, researchers found it sending malicious code to some mobile visitors, redirecting them to scam and betting sites.

Nothing on the victim sites changed; their tag was the same as years before. The registrar suspended the domain, and Cloudflare and Fastly offered safe mirrors. Because the response varied per browser by design, SRI could not have pinned it. Self-hosting the polyfills, or dropping them because modern browsers need few, was the real fix.

chalk and debug, 2025: phishing a maintainer

On September 8, 2025, the maintainer of chalk, debug, and 16 other small, very common packages typed their login and two-factor code into a phishing site posing as npm support. The attacker published versions with code aimed at browsers: it hooked fetch and wallet APIs to swap cryptocurrency addresses. The bad versions were pulled within about two and a half hours, but the 18 packages had over 2 billion downloads a week combined.

IncidentLink that brokeWhat would have helped
event-streamMaintainer handoverReviewing new dependencies in updates, fewer tiny packages
British AirwaysServer hosting a first-party copyScript change detection on payment pages, hosted card fields
ua-parser-jsMaintainer accountLockfile, npm ci, no install scripts, waiting before updates
Polyfill.ioDomain ownershipSelf-hosting, removing unneeded scripts, inventory
chalk and debugMaintainer account (phished)Lockfile, release-age delay, phishing-resistant 2FA for maintainers
Shai-Hulud worm (Sept 2025)npm tokens stolen by install scripts, then used to publish moreNo install scripts, release-age delay

Check your understanding · Exercise 2

Which of these attacks would SRI on the script tag have stopped? Pick one.

Subresource Integrity: run only the file you reviewed

When you load a library from a CDN, you trust that the bytes at that URL will not change. Subresource Integrity (SRI) lets the browser check that trust instead of assuming it. You put a cryptographic hash of the exact file in the tag. The browser downloads the file, hashes it, and runs it only if the two hashes match.

A hash is a short fingerprint of a file. Change one byte and the fingerprint changes completely, and in practice nobody can craft a different file with the same SHA-384 fingerprint. So the hash is a promise: this tag runs exactly this file, or nothing.

An annotated script tag in index.html. Its src points to lib@4.4.1, a versioned URL, so the file never changes. Its integrity attribute holds sha384- followed by the hash of the exact bytes you reviewed. Its crossorigin attribute is anonymous, which fetches the file in CORS mode without cookies so the browser may read the bytes and hash them.index.html<script src=".../lib@4.4.1/lib.js" integrity="sha384-k9Vh..." crossorigin="anonymous">version in the URL:this file never changeshash of the exact bytesyou reviewedCORS, no cookies:browser may read bytesmiss one part: the check fails, or proves nothing
All three parts are needed. Without the version, the hash breaks on the next update. Without crossorigin, the browser cannot read the bytes, so the check fails.

What the browser does, step by step

  1. The parser finds a script (or link rel="stylesheet") with an integrity attribute.
  2. Because of crossorigin="anonymous", it sends a CORS (Cross-Origin Resource Sharing) request without cookies. The CDN answers with Access-Control-Allow-Origin: *, which lets your page read the bytes.
  3. When the download finishes, the browser hashes the whole body with the named algorithm.
  4. It compares the result with the value in integrity. If the attribute lists several hashes, it uses the strongest algorithm present and accepts the file if any hash for that algorithm matches.
  5. On a match the script runs as usual. On a mismatch the browser does not run it, fires the element's error event, and logs a console error. Listen for that event if you want to log the failure or load a fallback.
A sequence diagram between the page, the browser, and the CDN. The page has a script tag with an integrity attribute. The browser sends a GET in CORS mode without cookies to the CDN. The CDN replies with the bytes and Access-Control-Allow-Origin star. The browser hashes the bytes with SHA-384 and compares them with the integrity value. On a match the script runs; on a mismatch the browser fires an error event and the script does not run.PageBrowserCDNscript with integrityGET (CORS, no cookies)bytes + ACAO: *sha384(bytes)vs integrity valuematch: run itno match: error event
The hash is checked after the whole file arrives and before a single line runs. A mismatch fails closed: nothing runs, and the page hears an error event.

Note step 5: SRI fails closed. A changed file is not run, which is right for security, but the feature that needed it is now broken. A page that cannot live without a CDN library should fall back to a self-hosted copy, or just self-host.

Making the hash

text
# Hash the exact file you reviewed, never a file fetched at build time
# from a URL you do not control.
openssl dgst -sha384 -binary chart.min.js | openssl base64 -A
# k9Vh...Qe2X  (64 base64 characters for SHA-384)

# The tag then carries it with the algorithm prefix:
# integrity="sha384-k9Vh...Qe2X"
SHA-384 is the common choice. sha256 and sha512 also work.

Bundlers can add hashes to your own chunks on a separate CDN host; webpack has the webpack-subresource-integrity plugin. Public CDNs such as cdnjs and jsDelivr show a ready-made SRI tag for each pinned file.

Why crossorigin matters

SRI must read the bytes to hash them, and a page may read a cross-origin response only if the server allows it with CORS. So a cross-origin file with integrity must be fetched in CORS mode. Forget crossorigin="anonymous" and the check fails even if the file is perfect. Use anonymous (no cookies) unless the file truly needs credentials.

Try
Fetch settings
Step 1, CORS fetch: bytes readable. Step 2, hashing.... Result: .... The integrity attribute expects a hash; the file hashes to a hash.1. fetch ok2. mismatch...CORS fetch: bytes readableexpectedhashing...this filehashing...
One changed character gives a completely different hash. The browser refuses to run the file.

Where SRI cannot help

SRI needs a file that never changes. Many third-party scripts are built to change:

  • Versionless loaders. Analytics tags, tag managers, and ad scripts live at one URL that the vendor updates in place. Pin a hash and the next vendor update breaks your site.
  • Responses that vary per visitor. Polyfill.io returned different code per browser. Google Fonts CSS returns different @font-face rules per browser. One hash cannot match them all.
  • Scripts loaded by other scripts. If a pinned loader adds more scripts, those have no integrity unless the loader sets it. Most loaders do not.
  • Code bundled from npm. SRI checks delivery, not content. A malicious package inside your bundle is hashed along with the rest.

For versionless vendor tags you are trusting the vendor. You can self-host a pinned copy (if their terms allow it), isolate the tag, watch it for changes, or accept the risk knowingly. SRI is the right tool for pinned libraries, fonts, and stylesheets on CDNs, and for your own assets on a separate host.

Check your understanding · Exercise 3

You added integrity to script src="https://cdn.other.example/lib@2.1.0/lib.js" and now the script never runs, even though the file is correct. What could be wrong? Select all that apply.

Your npm packages are third-party code too

Most code in your own bundle came from npm. A mid-size React app often has more than a thousand packages in its lockfile. Their code runs in the browser with your origin, and their install scripts run on your laptops and CI servers, which hold secrets such as deploy keys and npm tokens.

Lockfiles: the same bytes every time

package.json usually lists ranges, not versions: "debug": "^4.3.4" means any 4.x at or above 4.3.4. Two installs a day apart can get different code. A lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock) records the exact version picked for every package in the tree, where it was downloaded from, and an integrity hash of the tarball.

On the left, package.json lists debug with the range caret 4.3.4, which means any 4.x version at or above 4.3.4 and can mean a new version tomorrow. An arrow leads to package-lock.json on the right, whose entry for debug pins version 4.3.7, the resolved registry URL, and an integrity value starting sha512. Below, npm ci installs exactly this entry, checks the hash, and fails on any mismatch.package.json"debug": "^4.3.4"any 4.x from 4.3.4 upmay differ tomorrowpackage-lock.json: debugversion: 4.3.7resolved: registry URLintegrity: sha512-...npm ci: install exactly this, check the hash, fail on mismatch
package.json says what you would accept; the lockfile says what you actually ship. npm ci refuses to install anything else.
  • Commit the lockfile. It is the list of code you actually ship. Changes to it should show up in review like code changes.
  • Install with npm ci in CI (or pnpm install --frozen-lockfile). It installs exactly what the lockfile says, checks every tarball hash, and fails if package.json and the lockfile disagree. Plain npm install may update the lockfile.
  • Read lockfile diffs. A small feature should not add 40 new packages. A new package from an unknown author, or a resolved URL pointing at a strange host, deserves a question.

A lockfile is why ua-parser-js and chalk missed many teams, but it does not protect whoever runs an update during the bad hours.

Updating on purpose, a little late

You still have to update, because old versions collect known bugs. A bot such as Dependabot or Renovate opens one small pull request per update, so each change is reviewed and tested. Most malicious versions are pulled within hours or days, so a short delay before adopting new releases removes most of the risk. Renovate and pnpm (since 10.16) both have a minimumReleaseAge setting for this.

Auditing: known holes and fake signals

npm audit compares your lockfile with the GitHub Advisory Database of known vulnerabilities and lists affected packages by severity. Run it, or a similar scanner, in CI, and read the results with care: many findings are in dev-only tools that never reach the browser. Separate "ships to users" (npm audit --omit=dev) from "runs on our machines".

An audit cannot see fresh malware nobody has reported yet. The other controls in this chapter catch those.

Install scripts and provenance

npm runs a package's preinstall, install, and postinstall scripts by default, with your user's rights. That is how many attacks steal tokens from laptops and CI. pnpm 10 and later skips dependencies' install scripts unless you list them as allowed. With npm, set ignore-scripts=true in .npmrc and run the few needed scripts by hand.

Provenance answers "was this tarball built from the source it claims?". Packages published from a supported CI (such as GitHub Actions) can carry a signed statement linking the tarball to a commit and a build, recorded in a public log (Sigstore). npm audit signatures checks registry signatures and provenance for your installed packages. Provenance does not prove the code is safe, but a new version that lacks it when earlier ones had it suggests a publish from a stolen token.

Typosquatting and dependency confusion

Some attacks publish a fake package and wait for a mistake. Typosquatting uses names one typo away from popular ones: in 2017, crossenv (the real one is cross-env) worked like the original and also sent the developer's environment variables to the attacker. Attackers also register names that AI coding tools tend to invent, packages that sound right but never existed.

Dependency confusion targets private packages. If your app depends on an internal name like acme-ui-utils, and someone publishes a public package with that name and a higher version, a badly configured installer may pick the public one. A researcher showed this in 2021 against dozens of large companies. The fix is a scope you own (@acme/ui-utils) mapped to your private registry in .npmrc.

ControlStopsDoes not stop
Lockfile and npm ciSurprise new versions in buildsA bad version you chose to update to
Release-age delayMost short-lived malicious releasesA long-hidden payload like event-stream
npm auditKnown vulnerable versionsUnknown, fresh malware
No install scriptsMalware that runs at install timeMalware that runs in the browser
Provenance checksSome publishes from stolen tokensBad code shipped through the real CI
Scoped private namesDependency confusionTypos in public package names

Check your understanding · Exercise 4

A teammate runs npm install on a fresh clone and the lockfile changes. Which statements are true? Select all that apply.

Isolating code you cannot fully trust

Integrity checks ask "is it the file I expect?". Isolation asks a different question: "if this file goes bad, what can it reach?". For a plain script tag the answer is everything; isolation moves each vendor somewhere the answer is much smaller.

Four zones where vendor code can run. On the main thread with your origin, a vendor script tag has full page access, so its blast radius is everything. In a web worker on your origin, a tag in Partytown reaches the DOM through a proxy, so its blast radius is most of the page. In an iframe on the vendor's origin, card fields or chat talk only through postMessage, so the blast radius is its own box. On your server, an event forwarder sends only the fields you choose.main thread, your originvendor script tagfull page accessblast radius: everythingweb worker, your origintag in PartytownDOM through a proxyblast radius: most of itiframe, vendor origincard fields, chatpostMessage onlyblast radius: its own boxyour serverevent forwardersends chosen fieldsblast radius: chosen data
Moving down and right shrinks what a bad vendor version can touch. The worker helps speed far more than safety, because its proxy reaches the page by design.

Sandboxed iframes on another origin

The strongest boundary the browser offers is the origin. Code in an iframe from https://widget.vendor.com cannot read your DOM, cookies, or storage, and you cannot read its; the two sides talk only through postMessage, which carries data, not power. The sandbox attribute also limits what the frame may do, such as submit forms, open pop-ups, or navigate the top page. The clickjacking and iframes lesson covers sandbox and safe postMessage handling.

This is how payment providers keep card numbers off your page. Stripe Elements, Adyen, and Braintree render each card field in their own iframe, so the digits are typed into the provider's origin and a skimmer on your page sees only an iframe. It also shrinks your scope under PCI DSS, the card industry's security standard.

The cost: an iframe is a separate document with its own memory and requests, and sizing it to its content takes messages back and forth. It only fits widgets that live in a box, such as chat, video, payment fields, and social embeds. An analytics script that must watch your clicks cannot.

Web workers and Partytown

A web worker runs JavaScript on a separate thread with no DOM access. Partytown, an open-source library first built at Builder.io, runs tag scripts in a worker and forwards their DOM and window calls to the main thread through a proxy. That frees the main thread, which helps INP, while scripts written for the page mostly still work.

Two caveats. Partytown is a performance tool, not a security boundary: the worker is on your origin, and the proxy reaches your page by design. And scripts that need synchronous layout reads, many events, or tight timing may break; the project itself warns it will not work in every case. Test each script you move and keep a way back (Next.js offers this as the experimental worker strategy of its Script component).

Moving tags to the server

Server-side tagging sends one event stream from your page to an endpoint you control (for example a Google Tag Manager server container on your own subdomain), and that server forwards data to each vendor. The vendors' code never runs in the browser, you decide which fields each one gets, and the page loads one small script instead of five. The cost is a server to run and pay for, and vendors whose features need browser code (session replay, chat, on-page tests) cannot move.

Decision Where should a vendor's code run?

Script tag in the page

Plain script with async or defer, ideally SRI-pinned or self-hosted

Pros

  • Simplest; works for every vendor
  • Full access to page events and DOM

Cons

  • Full access for an attacker too
  • Uses your main thread

Sandboxed cross-origin iframe

Vendor UI in iframe sandbox="allow-scripts ..." on the vendor's origin

Talk through postMessage with origin checks

Pros

  • Real security boundary
  • Often runs in its own process, off your main thread

Cons

  • Only fits boxed widgets
  • Extra document, memory, and sizing work

Web worker (Partytown)

Mark scripts type="text/partytown"; a worker runs them through a DOM proxy

Pros

  • Frees the main thread for tag-heavy pages

Cons

  • Not a security boundary
  • Some scripts break; test each one

Server-side forwarding

Page sends events to your endpoint; the server calls vendor APIs

Pros

  • No vendor code in the browser
  • You control exactly which data leaves

Cons

  • Server cost and upkeep
  • Only for data collection, not on-page features

What we pick: Payment fields, chat, and embeds belong in cross-origin iframes. Analytics and ad conversions move to server-side forwarding when the team can run it. What must stay as a script tag gets a small blast radius: HttpOnly cookies, a strict CSP, and no access to pages that handle cards or passwords.

Check your understanding · Exercise 5

Which statements about isolating vendor code are true? Select all that apply.

Tag managers and governance

A tag manager such as Google Tag Manager (GTM) or Tealium is a script you load once that then loads other scripts, based on rules set in a web dashboard. Marketing can add a new pixel on Friday afternoon without a deploy. That is the whole point, and also the risk.

A deploy pipeline outside your deploy pipeline

A custom HTML tag in a tag manager can contain any script. Anyone with publish rights in the container can put code on every page of your site, with your origin's power, no review, no tests, and no entry in your git history. If one of those accounts is phished, the attacker has the same reach.

Tag managers also get around some defenses by design. Their loader is versionless, so SRI does not fit. Under a nonce-based CSP with 'strict-dynamic', the tag manager snippet carries the nonce and every script it adds is trusted too (see the CSP lesson). That is the right policy, but CSP will not stop a bad tag published in the dashboard, so your controls here are about people and process.

Rules that work

  • Few publishers. Many people can edit; two or three can publish. Turn on two-step login for every account.
  • Allow and block lists. GTM reads gtm.allowlist and gtm.blocklist from the data layer in your page code. Block the custom HTML type there and no dashboard user can turn it back on.
  • An inventory. Every tag with its purpose, owning team, pages, data received, and last review date. A tag with no owner is a tag to remove.
  • Page scoping. Fire tags only on the pages that need them. No marketing tags on checkout, login, or account pages.
  • Change alerts. Alert on every container publish and review the diff like a pull request.
  • A kill switch. Know how to remove a tag, or the whole container, in minutes, without a full release.
A flowchart for a new vendor. First, do we need it at all? If no, do not add it. If yes, can it run server-side? If yes, use server-side tagging. If no, does it fit in a box? If yes, use a cross-origin iframe. If no, it becomes a script tag with guard rails: an owner, a budget, page scope, CSP, SRI if pinned, and a kill switch.need it at all?nodon't add ityesserver-side possible?yesserver-side taggingnofits in a box?yescross-origin iframenoscript tag, with guard railsowner, budget, page scope, CSP, SRI if pinned, kill switch
Each question removes risk before the next one. Only the vendors that survive all three get a script tag, and that tag comes with rules.

Payment pages have rules of their own

The skimming attacks above led the card industry to add script rules to PCI DSS 4.0 (now 4.0.1), mandatory since March 31, 2025. For scripts on payment pages, requirement 6.4.3 asks for an inventory with a written reason for each script, a way to confirm each is authorized, and a way to assure its integrity. Requirement 11.6.1 asks for a mechanism that alerts on unauthorized changes to those scripts and to security-relevant HTTP headers, as the browser receives them.

Teams usually meet these with hosted payment fields, a short script list, a strict CSP with reporting, and a monitoring service that loads the page and compares its scripts. The same habits suit any page with passwords or personal data: know every script, why it is there, and when it changes.

Check your understanding · Exercise 6

Your site uses a nonce-based CSP with 'strict-dynamic', and Google Tag Manager carries the nonce. A phished marketing account publishes a custom HTML tag with a skimmer. What happens? Select all that apply.

The performance and privacy cost

Third-party scripts are among the most common causes of slow pages, and often the biggest privacy risk on a site. Both costs are easy to miss, because each tag looks small on its own.

Main-thread time is the real price

The bigger cost is usually main-thread time, not bytes: the milliseconds spent parsing, compiling, and running a script on the thread that also handles taps and paints frames. Any piece of work over 50 ms is a long task, and a tap that lands during one waits for it, which shows up directly in INP. On a mid-range phone, the same script can take several times longer than on a developer laptop.

A few patterns make it worse:

  • Render-blocking tags. A classic script in the head without async or defer stops HTML parsing until it downloads and runs. Client-side A/B testing tools often ask for this, plus a snippet that hides the page until the test loads, which delays LCP (Largest Contentful Paint) for every visitor.
  • Work on every event. Session replay and some analytics tools listen to scroll, input, and DOM changes, so they cost something on every interaction.
  • New connections. Each new domain needs DNS, TCP, and TLS before the first byte, often 100 to 300 ms on mobile. Ten tags on ten domains is ten handshakes.
  • Layout shifts. Ads, consent banners, and embeds that appear without reserved space push content down and hurt CLS (Cumulative Layout Shift).
A bar chart of illustrative main-thread time on a mid-range phone. Your app takes 420 ms. The third parties take 260 ms for the chat widget, 180 ms for A/B testing, 150 ms for session replay, 120 ms for the ad pixel, and 90 ms for analytics, 800 ms in total, nearly twice your own app.your app420 mschat widget260 msA/B testing180 mssession replay150 msad pixel120 msanalytics90 msthird parties: 800 ms, nearly twice your own code
Example numbers, but a common shape: no single tag looks big, yet together they cost more main-thread time than the product itself. Measure your own per vendor.

Measuring what each vendor costs

Measure the cost per vendor. Lighthouse groups main-thread time and bytes by third-party entity, and the Chrome DevTools Performance panel shows which third party owns each task. In the field, the Long Animation Frames API (PerformanceObserver with type long-animation-frame, Chromium only) reports the scripts in each slow frame with their source URL, so your real-user monitoring can blame a specific vendor. A simple test that always works: block the vendor's domain in DevTools and compare.

Then give third parties a budget, as the performance budgets lesson describes: for example, at most 150 KB of compressed third-party JavaScript and 200 ms of main-thread time on a mid-range phone. A new tag must fit, or something else must go.

The privacy cost

A script on your page sees what your users see. Analytics and ad scripts usually send the page URL, referrer, and device details to the vendor, so search terms, emails, or IDs in your URLs leave too. Session replay tools record what users type unless fields are masked, and researchers have found them capturing passwords on real sites. In 2022, The Markup reported that the Meta Pixel on many US hospital sites sent details of appointment bookings to Facebook.

  • Cookies. Third-party cookies let a vendor follow one user across every site that loads its tag. Safari blocks them and Firefox partitions them per site by default. Chrome still allows them by default, after Google dropped its plan to remove them. Do not build on them, and do not assume they are gone.
  • Fingerprinting. Some scripts build an identifier from the browser's traits (fonts, screen, GPU, canvas output) to track users who block cookies. It is hard to detect and often breaks browser policies.
  • Law and consent. In the EU and UK, most non-essential tracking needs consent before it runs (GDPR and the ePrivacy rules). A banner that does not actually hold tags back until the user decides is not compliance.

The frontend controls are concrete: keep personal data out of URLs, set Referrer-Policy: strict-origin-when-cross-origin (the browser default today) or stricter, mask inputs in session replay by default, load tags only after consent, and send the minimum fields through server-side forwarding when you can.

Tags on the page
Guards
Compromise
4 tags loaded: 885 KB, 680 ms of main-thread time, 4 new domains at load. Attack: Nothing. Result: no attack, pick a compromise to test.4 tags, 4 new domains at loadKB downloaded885 KBmain-thread ms680 msno attackpick a compromise to testchoose a compromise above
Example numbers for a mid-range phone. Facades cut the load cost; SRI only protects pinned files; CSP stops injected scripts but trusts the vendors you load. Removing a tag is the only guard that works against everything.

Think first

A client-side A/B testing tool asks you to put its script, synchronously, at the top of the head, plus a snippet that hides the page for up to 4 seconds. Why does it ask, and what would you push back with?

Loading third-party code without hurting the page

Once a tag has earned its place, how you load it decides most of its speed cost. The loading strategies lesson covers the general tools; here they are for vendor code, from least to most deferred.

async, defer, and later

StrategyWhen it runsGood for
Plain script in headBlocks parsing until downloaded and runAlmost nothing from a third party
asyncAs soon as it downloads, in any order, possibly before the page is parsedIndependent tags like analytics loaders
deferAfter parsing, in document order, before DOMContentLoadedScripts that need the DOM or each other
After load or idleInjected after the load event or in requestIdleCallbackChat, feedback, and survey widgets
On interactionInjected when the user clicks or focuses the widgetVideo, maps, chat, social embeds

Framework helpers wrap these choices: the Next.js Script component has beforeInteractive, afterInteractive (the default), and lazyOnload strategies. Default a new tag to lazyOnload unless someone can explain why it must run earlier. For a tag needed early, preconnect to its domain to start the handshake sooner, but only for one or two domains, because each preconnect costs work too.

Facades: load the heavy thing on click

A facade is a light stand-in that looks like the real widget. A YouTube embed is the classic case: the real iframe pulls in several hundred kilobytes of JavaScript even if nobody presses play. A facade shows the thumbnail and a play button, built from one image and a little of your own code, and swaps in the real player on click. Most visitors never click, so most never pay.

Two side-by-side windows of a product page with a video. On the left, the real YouTube iframe is embedded on load, pulling the player's JavaScript and many requests, so every visitor pays even if nobody presses play. On the right, a facade shows one thumbnail image and a play button made by your own code; the real player loads only when the user clicks.real embed on loadplayer JS, many requestsevery visitor paysfacadePlayone thumbnail imageplayer loads on click
Most visitors never press play. With a facade, only the ones who do pay for the player, and the vendor is not contacted until they choose to.

The same idea works for chat (a button that loads the chat script on click), maps (a static image), and social embeds. The facade must be a real button with an accessible name such as "Play video: Product tour", and the click should move focus into the loaded widget so keyboard and screen reader users are not lost. Privacy improves too: the vendor is not contacted until the user chooses, and YouTube also offers a youtube-nocookie.com domain for embeds.

Self-hosting or the vendor's CDN

A library such as a chart package or a font can come from a public CDN or from your own domain. The old argument for public CDNs was a shared cache: a visitor who loaded jQuery from a CDN on one site would already have it on yours. That argument is gone, because browsers now partition the HTTP cache by top-level site: Safari since 2013, Chrome since version 86 (2020), and Firefox since version 85 (2021).

Decision Should this library come from a public CDN or your own domain?

Public CDN, versioned URL with SRI

cdn.jsdelivr.net/npm/lib@4.4.1/... with integrity and crossorigin

Pros

  • No build changes
  • SRI catches tampering

Cons

  • An extra connection to another domain
  • No shared cache benefit anymore
  • Outage or block of the CDN breaks the feature

Self-hosted from your origin or CDN

Bundle the pinned npm version, or copy the file into your static assets

Pros

  • No new connection; uses HTTP/2 or 3 on your domain
  • A sold or breached third-party domain cannot touch you
  • Simpler CSP

Cons

  • You own updates, including security fixes
  • Still trusts the npm package, so lockfile rules apply

Vendor-hosted, versionless

The vendor's own tag.js URL, updated in place

Pros

  • Always current; vendor fixes bugs for you
  • Often required by the vendor's terms

Cons

  • No SRI possible
  • Vendor controls the code that runs on your page

What we pick: Self-host libraries and fonts by default. Use a versioned public CDN with SRI only when you cannot bundle. Accept versionless vendor URLs only for services that require them, and treat those as the tags to isolate, scope, and monitor.

Check your understanding · Exercise 7

A product page embeds a YouTube video below the fold and a support chat. Which changes reduce cost without losing the features? Select all that apply.

Using it in a system design interview

Any design with checkout, login, analytics, ads, embeds, or a marketing site has third-party code. You rarely get a whole question on it, but you get credit for raising it at the right moment in a sentence or two, and going deeper if asked.

When to bring it up

  • Payments or login in scope. Say card fields live in the provider's iframe, and these pages load almost no third-party scripts.
  • Analytics or experiments in the requirements. Mention the tag budget, late loading, and server-side A/B assignment.
  • Embeds (video, maps, chat). Mention facades.
  • Performance targets. Add that third-party main-thread time counts toward LCP and INP goals and is measured per vendor.
  • A design system or shared library. Mention lockfiles, reviewed updates, and provenance for what you publish.

What to draw

One box diagram covers it. Draw the page with your bundle inside, and next to it the vendor boxes, each with an arrow showing how it gets in (script tag, iframe, or server-side forwarder) and which pages it may run on. Then label the controls on the arrows: SRI on the pinned CDN library, nonce and 'strict-dynamic' on the loader, sandbox and postMessage on the iframe, and a consent gate on analytics.

How deep to go by level

LevelWhat a strong answer shows
JuniorThird-party scripts run with full page access; use async or defer; keep tags off checkout; know what SRI checks.
Mid-levelLockfiles and npm ci, SRI with crossorigin, facades, hosted payment fields, measuring main-thread time per vendor.
SeniorIntegrity versus isolation versus inventory; why SRI fails for versionless tags; CSP with 'strict-dynamic' trusts tag managers; server-side tagging; release-age delays and provenance; consent gating.
StaffGovernance as a system: tag inventory with owners, publish rights, change detection on payment pages, a third-party budget in CI, a kill switch, and an incident runbook for a compromised vendor.

If asked: a vendor is compromised right now

  1. Remove or block the tag: tag manager pause, feature flag, or a CSP change that drops its host. Minutes matter.
  2. Find the blast radius: which pages loaded it, since when, and what those pages hold (cards, passwords, tokens).
  3. Rotate what could have leaked: sessions, API keys exposed to the browser, and any tokens readable by script.
  4. Tell the people who need to know: security, legal, and, if personal or card data was exposed, the regulators and users as the law requires.
  5. Fix the gap: pin, self-host, isolate, or drop the vendor, and add the change detection that would have caught it sooner.

Check your understanding · Exercise 8

You are designing the checkout flow of a large store. Which choices would you say out loud? Select all that apply.

Summary

  • Every third-party script and every npm package runs with your origin's full power. Treat it like code merged without review.
  • Think in three questions: is it the code I expect (integrity), what can it reach if bad (isolation), and do we need it (inventory).
  • SRI pins a file by hash and needs crossorigin. It fits versioned libraries, not versionless tags or code already in your bundle.
  • Lockfiles with npm ci, small reviewed updates, a release-age delay, no install scripts, and scoped private names stop most npm attacks.
  • Put payment fields, chat, and embeds in cross-origin iframes; move data collection server-side; keep high-value pages nearly tag-free.
  • A tag manager is an unreviewed deploy path: limit publishers, restrict tag types, alert on publishes, and keep a kill switch.
  • Budget third-party main-thread time per vendor, load tags late or on interaction, and use facades for heavy embeds.
  • Third-party tags see what users see. Keep personal data out of URLs, mask session replay, and run tags only after consent.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Why is a third-party script on your page a security risk even if the vendor is honest?

Question 2

What does Subresource Integrity do, and when can you not use it?

Question 3

How do you protect a frontend build from a malicious npm release?

Question 4

Marketing wants five new tags through the tag manager. How do you keep the site fast and safe?

Question 5

Does a strict CSP protect you from a compromised third-party script?

Practice it

These design problems lean on third-party scripts and supply chain.

References

Read next