squirrelscan
GitHub

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 scanCloud scan
Account and creditsNone: free, works logged outSigned in; included in the audit base
ReadsThe pages the crawl already fetched, their response headers and the first-party bundles it already downloaded, wholeThe same pages and headers, with each bundle cut to the payload caps, sent to the cloud service
Extra requestsNoneNone
Change historyNone: no “added” or “removed” since the last scanCompared with the previous scan of the domain, with the first scan marked
Runs whenEvery 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 sectionSigned-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 -m caps 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 = 0 switches 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: its script, link, img, iframe, embed, object and source tags 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/javascript and <hash>-module, Partytown, a consent tool’s text/plain or javascript/blocked, WP Rocket’s rocketlazyloadscript, Perfmatters’ pmdelayedscript, NitroPack’s nitropack/inlinescript, SiteGround’s text/sg-delayed-inline, Babel standalone’s text/babel, text/prosyo-later and 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-blocked and text/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 says lazy, delay or javascript (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 a script element, or a next/script component, carries is read: its src, its inline __html and its string children. 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 an id is read like a short string would be, prose is not. A next/script component is recognised by its props (a strategy, or an id with inline code: __html, or text children that contain JavaScript such as function, => or an assignment; a src counts beside an id or a strategy, not alone). An inline snippet with only an id, no strategy and no JavaScript token in its text (a bare loadTag('...');) is not read; a client component that has an id and a src or code-like text is, because it cannot be told from next/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 of next/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 no id and no strategy; 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 its imports and scopes tables 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 as three.module.js and 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 a scopes table 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-shims import 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’s uri, url or id string counts as a script URL (a bundler writes {"id": "<script URL>", "uuid": "<uuid>"}), and an id only 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; the uuid and 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 its rel loads 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 another rel (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 a your... placeholder or one repeated character) as the whole href of such a <link>, a preconnect or dns-prefetch (a list of URLs that only contains it does not count), detects Supabase; the same origin in an alternate or canonical link 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 --http does 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 every type that is not empty, module or 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-8 makes it data, as in a browser; a delay, cache or consent wrapper such as text/plain or rocketlazyloadscript is the exception above). The script ends at </script followed 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 </script and 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 word template in a script, a comment, a <textarea> or an attribute pairs with nothing; a declarative shadow root (a first shadowrootmode of exactly open or closed) 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":true in the shopify-features JSON 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 in squirrel.toml, or set it to 0 to 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
LimitDefaultWhere
Bytes read per bundle8 MB local, 64 KB cloud payload[tech_detect] bundle_bytes (sets both)
Bytes read per page32 MB local, 512 KB cloud payloadfixed
Bytes read per audit64 MB local, 1 MB cloud payloadfixed
Time per page30 seconds localfixed
Candidate matches a detector inspects inside strings or comments before the real one64fixed
  • 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 to bundle_bytes with 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.truncated with reasons (bundle-bytes, page-bytes, audit-bytes, time-budget, script-too-large, script-limit) and the number of scripts read in part or not at all (script-too-large counts first-party scripts the crawl dropped for being over its 5 MB fetch cap, script-limit the 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 no truncated field. 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 with origin: "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 value local is marked, anything else in a loaded report is ignored), is never in added because 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. truncated describes 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 and truncated, 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 set bundle_bytes = 0 to 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 to publishableKey. A Clerk key’s body is the base64 of its Frontend API host (a clerk. host ending in $, such as clerk.<domain>$ or <name>.clerk.accounts.dev$), so a publishableKey: "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.reasons containing time-budget. The scan of a page the cloud service gets is stopped at 5 seconds.

Results are grouped by category:

CategoryExamples
CMS & EcommerceWordPress, Shopify, Webflow, Ghost, Contao, SilverStripe, Plone, Odoo, Lightspeed eCom, AI app builders (Lovable, Base44, Bolt, v0)
Frameworks & LibrariesNext.js, Astro, React, Vue, Svelte
Web Serversnginx, Apache, Caddy
HostingVercel, Netlify, AWS, GitHub Pages, Replit, managed WordPress hosts (WP Engine, Kinsta, Pressable, WordPress VIP, SiteGround, Cloudways)
CDN & EdgeCloudflare, Fastly, Akamai, Azure Front Door
AnalyticsGoogle Analytics, Plausible, Cloudflare Web Analytics (one entry for the beacon), DebugBear, AppsFlyer, Vercel Web Analytics and Speed Insights
Tag ManagersGoogle Tag Manager, Cloudflare Zaraz, Matomo Tag Manager, Piwik PRO, Commanders Act
AdvertisingGoogle AdSense, ad networks
PaymentsStripe, PayPal, Shopify Payments
Support & ChatIntercom, Crisp, Zendesk
Security & ConsentOneTrust, Cookiebot, Borlabs Cookie, Complianz, Usercentrics, consentmanager, reCAPTCHA, hCaptcha
Fonts, Video & Media, Widgets & UIGoogle Fonts, YouTube, OneTrust
Caching & PerformanceWordPress caching and optimization plugins (WP Rocket, LiteSpeed Cache, W3 Total Cache, WP Super Cache, WP Fastest Cache, Autoptimize, Perfmatters)
SEO PluginsYoast SEO, Rank Math, All in One SEO, SEOPress
Image OptimizationShortPixel, Imagify, EWWW Image Optimizer, Smush
OtherjQuery, 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       OneTrust

Technologies 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.

Type to search…

↑↓ navigate↵ openesc close