Technology Detection
Identify the tech stack behind any site - CMS, frameworks, CDN, analytics, and more
Every audit, local or cloud, fingerprints the technologies a site is built with - 445 signatures across CMS and AI app builders, WordPress plugins and managed WordPress hosts, frameworks, CDN & hosting, analytics, tag managers, payment processors, chat widgets, fonts, security/consent tools, and backend and auth services - and surfaces them as a dedicated Technologies section in the report.
It’s informational: technology detection never affects your health score. It’s there to give you - and your agent - context about what you’re auditing.
What it detects
Detection runs against response headers, HTML, inline and external scripts, cookies, and meta tags, and reports versions where they can be determined. The crawler keeps every response header a signature reads, such as X-CDN, X-Sucuri-ID or Via (each value cut to 512 characters), so a header-only signature matches in a local audit too. A header a signature only checks for presence, such as a request id or a bot protection token, is kept as a marker, not its value. In an audit the CLI runs, a cookie is matched by its name: only the names of the cookies a page sets are passed to detection, never their values. A page squirrel stored before it kept these headers, and that the crawl cache reuses, is read without them until it is fetched again.
Local and cloud scans
There is one detector, with one set of fingerprints, and two ways it runs. A report says which one produced its Technologies section (technologies.source in JSON: local or cloud).
| Local scan | Cloud scan | |
|---|---|---|
| Account and credits | None: free, works logged out | Signed in; included in the audit base |
| Reads | The pages the crawl already fetched, their response headers and the first-party bundles it already downloaded, whole | The same pages and headers, with each bundle cut to the payload caps, sent to the cloud service |
| Extra requests | None | None |
| Change history | None: no “added” or “removed” since the last scan | Compared with the previous scan of the domain, with the first scan marked |
| Runs when | Every audit: on its own where the cloud scan did not run (logged out, offline, Quick), and beside the cloud scan on a signed-in audit, where its extra finds are merged into the one section | Signed-in cloud audits |
On a signed-in audit the two are merged into one section (see below). A local scan keeps no history, so a technology only it found has no scan diff, and a section from a local scan alone never says “No changes since last scan” and shows where the list came from instead. The cloud scan is the one that stores results per domain and reports the difference between scans.
Why a result can show little more than headers
A local scan can only match what the crawl fetched. When a run lists the server and CDN and little else, check these in order:
- Page sample. Detection reads the home page first and up to 11 more crawled HTML pages. A Quick audit crawls the seed URL and the sitemap only, and
-mcaps the pages crawled, so a stack that only appears on pages that were not crawled is not seen. - Script bodies. A signature that matches script content needs the body of a script, and the only bodies read are first-party JavaScript the crawl already downloaded (see Script bundles). A third-party script is detected by its URL, never by its body, and an app whose stack lives only in its bundle was invisible before v0.0.140.
bundle_bytes = 0switches bodies off. - Script URLs. A signature that matches a script URL reads every
<script src>on the page and, since the local scan was aligned with the cloud one, up to 40 stylesheet and preload<link href>URLs a page, with well-known platform paths such as/wp-content/plugins/listed first. A signature that matches a script URL also reads the page’s markup: itsscript,link,img,iframe,embed,objectandsourcetags as written (a>inside a quoted attribute does not end a tag), and the text of each inline<script>that is not a data block, whatever type a cache, CDN, delay plugin or consent tool gives it so that the page runs it later (litespeed/javascript, Cloudflare Rocket Loader’s<hash>-text/javascriptand<hash>-module, Partytown, a consent tool’stext/plainorjavascript/blocked, WP Rocket’srocketlazyloadscript, Perfmatters’pmdelayedscript, NitroPack’snitropack/inlinescript, SiteGround’stext/sg-delayed-inline, Babel standalone’stext/babel,text/prosyo-laterand the other delay-plugin names the corpus holds (bv_inline_delayed_js,pslazyscript,airfleetlazyloadscript,lazyjs,o/js-lzl),easyoptscript, and the consent blockers’application/x-imovisor-consent,javascript/blocked,text/javascript-blocked,text/javascript_blocked,application/ecmascript-blockedandtext/blocked; the list is explicit and a type is matched whole, ASCII case-insensitively, never by a word in it, so any other type, including a custom one that sayslazy,delayorjavascript(text/x-lazy-cache,application/javascript-example) and one with a MIME parameter or a space, is data and an unknown wrapper type is cut until corpus evidence adds it), up to 16 KB each, with 8 MB of inline text read per page. A Next.js App Router flight script (self.__next_f.push) is not read as text at any size: the chunks of all the page’s flight scripts are decoded together (up to 2 MB each, 8 MB of inline text per page) and only what ascriptelement, or anext/scriptcomponent, carries is read: itssrc, its inline__htmland its stringchildren. A snippet of 1 KB or more is a separate text row of the payload that the element points at, and it is read there: code-like long text in a component with anidis read like a short string would be, prose is not. Anext/scriptcomponent is recognised by its props (astrategy, or anidwith inline code:__html, or text children that contain JavaScript such asfunction,=>or an assignment; asrccounts beside anidor astrategy, not alone). An inline snippet with only anid, nostrategyand no JavaScript token in its text (a bareloadTag('...');) is not read; a client component that has anidand asrcor code-like text is, because it cannot be told fromnext/script, because a production build does not name its module. A page uses one component reference for every use of a component, so once any use of a reference has one ofnext/script’s loading strategies (afterInteractive,lazyOnload,beforeInteractive,worker), every use of that reference is read as a script, including a bare{ type, children }loader with noidand nostrategy; a reference never seen with a strategy, or with a strategy that is not one of those four, is not. That search is bounded: it stops after about 400,000 nodes of the rows that mention a strategy and does not look deeper than 64 levels, so a bare use whose strategy use comes only after that, or nested deeper, is missed, without a note in the report. An import map (<script type="importmap">) is the exception among data blocks: the URLs itsimportsandscopestables map to are what the page loads as modules, so each counts as a script URL (a map of up to 65,536 characters (UTF-16 code units, not bytes), 256 URLs a map and 512 a page, 64 KB of tags in all, each URL up to 2,048 characters; only an http or https URL, or a path starting with/,./or../, so a bare name such asthree.module.jsand any other scheme are not read; no value with a quote, angle bracket, backtick, backslash, whitespace, control character (C0, DEL, C1) or a lone surrogate; a relative path is matched as written. An entry past a bound or rejected is not read, and the rest of the page is read as usual; the number left out is counted internally and appears in no report). A map with only ascopestable counts, as scoped mappings are real; an entry the page never imports still counts, as evidence of the library and not proof that it runs; a map inside a<template>is inert and is not read;es-module-shimsimport maps (importmap-shim) are out of scope, while its keys and any other JSON do not. A standalone-HTML bundler’s resource list (<script type="__bundler/ext_resources">, a JSON array of objects) is read the same way, with the same bounds: only each entry’suri,urloridstring counts as a script URL (a bundler writes{"id": "<script URL>", "uuid": "<uuid>"}), and anidonly when it is an http or https URL or a path starting with/,./or../, so a bare token or a UUID id is not read; theuuidand every other text in the list (a description) are not read. An inline<script type="module">that imports a URL is read as inline text like any other short script. So the URL an inline loader writes, such as Microsoft Clarity’s standard snippet, is found whether or not the page has script bodies attached; the result does not depend on the byte caps. A<link>counts only when itsrelloads the resource it names or opens a connection to a service’s origin (stylesheet,preload,modulepreload,prefetch,prerender,preconnect,dns-prefetch, in any case and among other tokens); a<link>with anotherrel(canonical,alternate,author,license,help,next,prev,me,icon,manifest,search) or with none describes or relates a URL and is not read, in the markup or as a tag attribute, so a vendor URL there is not detected (a CDN host written anywhere on the page can still name the CDN itself). A Supabase project origin (https://<20 character ref>.supabase.co, with or without a trailing slash and no path, not ayour...placeholder or one repeated character) as the wholehrefof such a<link>, apreconnectordns-prefetch(a list of URLs that only contains it does not count), detects Supabase; the same origin in analternateorcanonicallink or in an<a href>does not. Text between tags,<meta>text (a CSP<meta>included), JSON data blocks (JSON-LD,__NEXT_DATA__,__nuxt_data__), templates and the body of a larger inline script that is not a flight payload are not read, so a vendor named in a paragraph, linked or described is not detected as its script. A flight script’s JSON-LD and other data scripts, meta tags, links and the text of other elements are not read either, so a vendor the page only mentions is not detected there. A flight script longer than 2 MB is read up to 2 MB. A URL that only appears in a CSS@import, in script-built markup or after a client-side render is not in the fetched HTML, and--httpdoes no browser rendering. - Content the browser does not run or show. A signature that matches the page’s HTML does not read the text of a data
<script>: any script the browser does not run, which is everytypethat is not empty,moduleor a JavaScript MIME type (JSON, JSON-LD, an import map, speculation rules,text/x-*templates and any custom type; a MIME parameter such as;charset=utf-8makes it data, as in a browser; a delay, cache or consent wrapper such astext/plainorrocketlazyloadscriptis the exception above). The script ends at</scriptfollowed by whitespace,/or>, so</scriptx>ends nothing. A signature also does not read the content of a<template>(a<script src>or an iframe inside one is not detected either) or an HTML comment, so a vendor URL that only appears in page data, in a template or in commented-out code is not a detection. Not affected: comments that one detector of a signature matches on purpose (a WordPress plugin banner, the Jekyll and Mailchimp markers, Klaviyo’s app block marker, Font Awesome’s banner; the same signature’s loader detectors do not read comments, so a commented-out Klaviyo or Font Awesome loader is not a detection), CSS in<style>,<textarea>text, and every real tag and inline script. The page is scanned once for these regions, and a region is only left out when its start and end were both seen: a comment with no-->, a<template>with no</template>(a self-closing<template/>opens a template, as in a browser), a data script with no</scriptand everything after a tag over 64 KB stay in the text, so a malformed page is read as before. Template pairing follows the tags as written, innermost first, so the wordtemplatein a script, a comment, a<textarea>or an attribute pairs with nothing; a declarative shadow root (a firstshadowrootmodeof exactlyopenorclosed) is rendered, so it is read; any other value makes a plain, inert template. SVG and MathML get no special handling. One signature reads data script text on purpose: Shopify Payments (enabled in shop config) finds"shopifyPaymentsEnabled":truein theshopify-featuresJSON script, where a storefront states it; comments and templates stay cut for it. Version numbers are still read from the raw HTML. - Signatures. A technology with no fingerprint is not reported. The fingerprints are written from first-party research, never imported from a third-party catalog.
Script bundles
A single page app (Vite, Lovable, Bolt, Next.js) ships an almost empty HTML shell, and its libraries live in the script bundles. Detection reads each first-party bundle the crawl fetched whole (the free local scan, up to 8 MB; a signed-in audit’s cloud scan sends only the first 64 KB, see below), so a backend or auth service that only a bundle calls is still found: Supabase (a project URL passed with a key, or a quoted project URL anywhere in a bundle that ships supabase-js, whatever the key format), Firebase (the authDomain or storageBucket of the app config), Clerk (a publishableKey whose body decodes to a Clerk frontend host ending in $, handed to the provider) and Microsoft Clarity (the standard loader’s .src = "https://www.clarity.ms/tag/" + id assignment, as code: a bundle that only names the URL in text or a comment is not enough; a loader written another way is not found: the URL split by concatenation or held in a variable, { src: ... } or JSX, s["src"] = ..., or loader code inside a ${}). A config that comes after more than 4,095 look-alike matches inside strings (docs text, error messages) is not found, and the report does not say so: the search per detector stops at 4,096 candidates. In a bundle too big to send whole, the windows around a literal are likewise cut at the first 512 hits of that literal, also without a note in truncated.
A bundled app’s own code sits after the framework it ships with, so a bundle bigger than its cap is not read from the top alone. Each bundle gets its first 16 KB, then the text around the literals the detectors look for (a .supabase.co URL or supabase-js’s own notice and storage-key strings, a .firebaseapp.com domain, a pk_ key, and, for most other detectors, a literal taken from its pattern, such as Sentry.init(, posthog.init or the React devtools hook) and the end of the file, then the rest of the cap goes on the text those leave out, from the front: more of the start up to the first window, then each window’s end up to the next. A piece of text is sent once, and pieces that touch are one piece with no gap between them, so a call that crosses the end of the first 16 KB is read whole. The windows are shared out across detectors one round at a time (every detector’s first match before any detector’s second, the literals above first), up to 24 KB a bundle, and up to 6 KB of that (at most a quarter) is kept for the end of the file, so a detector that is common in a bundle cannot crowd out one that appears once. On a page with several bundles, every bundle’s windows are chosen before any bundle’s remainder or end takes room from the page budget (in rounds of 2 KB, 8 KB and 24 KB a bundle), so a small early script cannot use the room a later bundle’s windows need. The windows are searched in at most 12 MB of a page’s bundle text, shared fairly: each bundle gets its whole length when that is within an equal share, otherwise the start of it (at least 64 KB), and no bundle is skipped.
- It uses the bytes the crawl already fetched for the secret rules. No extra request is made, on the cloud or the local path.
- A bundle counts only where it is real code. A product named in a comment, in a string, or as a placeholder (
your-project) does not match. - The local scan sends nothing anywhere, so it reads every bundle whole up to its caps: 8 MB per bundle, 32 MB per page and 64 MB per audit. The crawl itself fetches a script only up to 5 MB, and only some of a page’s scripts (the first 10 to 50), so in practice a bundle over 5 MB, or a script past those, is never read. That is the script fetch limit, not a detection cap, and the report counts those scripts in
truncated(script-too-large,script-limit, below). A bundle that fits is matched from the first byte to the last, wherever the fingerprint sits. A script past a cap keeps its URL; the report says so (below). Change the per-bundle cap insquirrel.toml, or set it to0to match the page HTML and script URLs only:
[tech_detect]
bundle_bytes = 8388608 # 0 to 16777216 (16 MiB); unset = 8 MiB locally, 64 KB sent to the cloud| Limit | Default | Where |
|---|---|---|
| Bytes read per bundle | 8 MB local, 64 KB cloud payload | [tech_detect] bundle_bytes (sets both) |
| Bytes read per page | 32 MB local, 512 KB cloud payload | fixed |
| Bytes read per audit | 64 MB local, 1 MB cloud payload | fixed |
| Time per page | 30 seconds local | fixed |
| Candidate matches a detector inspects inside strings or comments before the real one | 64 | fixed |
-
A bundle past
bundle_bytes(only when you lower it) is not read from the top alone: the first 16 KB, then the text around the literals the detectors look for and the end of the file (the windows and a tail of up to 6 KB share 24 KB), then the rest of the cap on the text between them, from the front, so the bundle is read up tobundle_byteswith no text sent twice. -
When a byte cap or the time budget cut the local scan short, the report says so instead of listing fewer technologies without a word. The JSON report carries
technologies.truncatedwithreasons(bundle-bytes,page-bytes,audit-bytes,time-budget,script-too-large,script-limit) and the number ofscriptsread in part or not at all (script-too-largecounts first-party scripts the crawl dropped for being over its 5 MB fetch cap,script-limitthe first-party scripts the page references that the audit’s script limit left unfetched: it fetches a share of the page count, between 10 and 50); the text, markdown, llm and xml reports say it beside the list. A scan that read everything has notruncatedfield. A signed-in audit shows one merged section. The cloud scan sees each bundle cut to the 64 KB payload caps, so the free local scan, which reads bundles whole, always runs beside it and adds what it finds past those caps. The cloud items keep their change history (added,removed,firstScan); a technology only the local scan found is listed withorigin: "local"in the JSON report (origin="local"in the xml and llm reports,(local scan)after its name in the text, markdown, console and HTML reports, and a count in the summary line; only the exact valuelocalis marked, anything else in a loaded report is ignored), is never inaddedbecause it has no scan diff, and a version the cloud scan lacked is filled in from the local one. The local scan’s bytes are never sent to the cloud service: only its result is merged, and the payload caps stay.truncateddescribes the merged section: the payload’s cut is no longer a limit once the local scan read those bundles, so it carries the local scan’s own cut (a bundle over[tech_detect] bundle_bytes, a script limit or budget, the time budget) or is absent. If the local scan could not run, the cloud section is shown as it was, with the payload cut. A scan that detects nothing has no technologies section unless a cap or the budget cut it short: then the section is there with an empty list andtruncated, so the report does not read as a site that runs nothing. Such an empty section is not synced to the dashboard, which would clear the technologies an earlier scan found. -
Only first-party scripts the crawl already fetched are read: those on the audited host or its subdomains (a CDN on another domain is not), within the crawl’s script fetch limits, and only JavaScript bodies under 5 MB.
-
On a signed-in audit the bundle text sent to the cloud detection service is at most 1 MB per audit, 64 KB per bundle unless you raise
bundle_bytes. Bundles can contain keys, so setbundle_bytes = 0to send none. -
A key kept in a variable and passed by name (
publishableKey: key) is not matched; a Clerk key is recognised only where it is written out next topublishableKey. A Clerk key’s body is the base64 of its Frontend API host (aclerk.host ending in$, such asclerk.<domain>$or<name>.clerk.accounts.dev$), so apublishableKey: "pk_live_..."whose body is not that (a Stripe publishable key is passed under the same name) is not read as Clerk. -
A local scan that runs long on one page stops at its 30 second budget and reports what it found so far, with
truncated.reasonscontainingtime-budget. The scan of a page the cloud service gets is stopped at 5 seconds.
Results are grouped by category:
| Category | Examples |
|---|---|
| CMS & Ecommerce | WordPress, Shopify, Webflow, Ghost, Contao, SilverStripe, Plone, Odoo, Lightspeed eCom, AI app builders (Lovable, Base44, Bolt, v0) |
| Frameworks & Libraries | Next.js, Astro, React, Vue, Svelte |
| Web Servers | nginx, Apache, Caddy |
| Hosting | Vercel, Netlify, AWS, GitHub Pages, Replit, managed WordPress hosts (WP Engine, Kinsta, Pressable, WordPress VIP, SiteGround, Cloudways) |
| CDN & Edge | Cloudflare, Fastly, Akamai, Azure Front Door |
| Analytics | Google Analytics, Plausible, Cloudflare Web Analytics (one entry for the beacon), DebugBear, AppsFlyer, Vercel Web Analytics and Speed Insights |
| Tag Managers | Google Tag Manager, Cloudflare Zaraz, Matomo Tag Manager, Piwik PRO, Commanders Act |
| Advertising | Google AdSense, ad networks |
| Payments | Stripe, PayPal, Shopify Payments |
| Support & Chat | Intercom, Crisp, Zendesk |
| Security & Consent | OneTrust, Cookiebot, Borlabs Cookie, Complianz, Usercentrics, consentmanager, reCAPTCHA, hCaptcha |
| Fonts, Video & Media, Widgets & UI | Google Fonts, YouTube, OneTrust |
| Caching & Performance | WordPress caching and optimization plugins (WP Rocket, LiteSpeed Cache, W3 Total Cache, WP Super Cache, WP Fastest Cache, Autoptimize, Perfmatters) |
| SEO Plugins | Yoast SEO, Rank Math, All in One SEO, SEOPress |
| Image Optimization | ShortPixel, Imagify, EWWW Image Optimizer, Smush |
| Other | jQuery, Sentry, Partytown, WalletConnect / Reown, Magic, backend and auth services (Supabase, Clerk, Firebase) |
WordPress plugins and hosts
A WordPress plugin is recognised by something it writes into the page: an asset URL under wp-content/plugins/<slug>/ (or the cache directory it fills) on a script, stylesheet or image tag, the HTML comment it adds (This website is like a Rocket for WP Rocket, Performance optimized by W3 Total Cache, the Yoast SEO and Rank Math comments), or its own JSON-LD block (yoast-schema-graph, rank-math-schema, aioseo-schema). Its name in page text, in a code sample or in a link does not count. A managed host is recognised by the response header it sets (x-kinsta-cache, x-powered-by: WP Engine, x-sg-cache), reported as header:<name>. LiteSpeed Cache is recognised from its page comment or plugin assets, not from the x-litespeed-cache header, which any LiteSpeed-cached site sends. Pressable, WordPress VIP, SiteGround and Cloudways report at medium or low confidence because their headers are the least documented.
Hosting platforms that live on a host suffix are read from the audited page’s own host, not from URLs inside the page: Firebase Hosting is *.web.app, *.firebaseapp.com or Firebase Hosting’s own Vary: x-fh-requested-host header; Surge is *.surge.sh or the Server: Surge header; Railway is *.railway.app (including up.railway.app) or the Server: railway header; AWS Amplify is *.amplifyapp.com; and ActiveCampaign Pages is *.ac-page.com. Amplify and ActiveCampaign Pages have no response header signal here, so a site on its own domain behind either is not detected as hosted there. A site on its own domain that merely mentions firebaseapp.com in its config, or loads a third-party script from a *.web.app host, is not reported as hosted there; that is intended.
Shopify storefronts
A storefront’s static HTML names more than its theme. Klaviyo is detected from its theme app block marker (shopify://apps/klaviyo-email-marketing-sms/..., usually in an HTML comment), from the inline onsite loader (window._klOnsite=window._klOnsite||[], window.klaviyo=...new Proxy(), from the static.klaviyo.com script and from the legacy _learnq.push( queue, so it is found in a local --http audit even though its script is injected after load. Script loading is recognised from quoted or unquoted src, long script attributes, data-src, a preload link, an inline element.src = "//static.klaviyo.com/..." assignment and a collected URL passed through a proxy. A plain anchor, an escaped code sample and a commented-out script tag do not count as loading the script. Shopify Payments (enabled in shop config) is detected from "shopifyPaymentsEnabled":true in the shop config, inline or in a JSON data script such as shopify-features; it is a flag the merchant set, not a script the page loads, which is why the name says so and its confidence is medium. A page that only links to or names a vendor, or sets the key to false, matches neither. The Klaviyo app block and JavaScript markers are read wherever they appear in the HTML, so a developer blog that quotes one of those markers in a <pre>, or a JSON block that carries it, is detected too. The payment method icons a theme renders (pi-paypal, pi-klarna, …) show what the shop advertises, not what loads, and are not reported.
A typical result:
TECHNOLOGIES
Detected tech stack - informational, not part of the score. No changes since last scan.
🧩 Frameworks & Libraries Astro 6.3.7
🌐 CDN & Edge Cloudflare
📊 Analytics Cloudflare Web Analytics
🏷️ Tag Managers Google Tag Manager
🔒 Security & Consent OneTrustTechnologies appear in every output format - console, HTML, Markdown, JSON, XML, and LLM - with logos in the visual formats.
When it runs
- Logged in - detection runs automatically as part of every cloud audit. One detection per domain, regardless of how many pages you crawl.
- Logged out or
--offline- the cloud detection is skipped, like every other cloud feature. The CLI runs the same signatures over the crawled pages instead, so the Technologies section still appears, without the change history.
Detection is best-effort and never fails the audit: if the service is unavailable, the Technologies section is simply omitted and you’re not charged.
Cost
Included in the audit base charge - a single detection per domain, not per page. See Credits & Pricing.
Change history
squirrelscan keeps a per-domain record of what it has detected over time, so you can see when a site adopts or drops a technology - a CMS migration, a new analytics vendor, a CDN switch.
- Current snapshot - free, shown on every audit. The report notes whether anything changed since the last scan (“No changes since last scan”).
- Historical timeline - the full add / remove history for a domain is available to any signed-in account, free or Pro, in the dashboard.