developer-tools
How Website Technology Detection Works (and Why It's Often Wrong)
How website technology detection works: the meta tags, HTML fingerprints, headers and cookies every detector reads, why they misfire, and how to hide yours.
Point any “what is this site built with” tool at getbootstrap.com and a surprising number of them will tell you it uses Tailwind CSS. Point one at tailwindcss.com and it will tell you Bootstrap. Neither answer is a bug report against one specific tool — it’s the whole category admitting what it actually does, which is grep a page for substrings and hope. I ran exactly that test against our own detector before writing this, and it failed both ways, which is the honest place to start.
Here is what every one of these tools reads, the four ways the signals lie, and what you can do about it in either direction — finding out, or not being found out.
The six signals every detector reads
Every technology detector — Wappalyzer, BuiltWith, WhatRuns, ours — is built from the same small set of observable facts. The differences are in the ruleset size and in whether the tool runs JavaScript.
| Signal | Example | What it actually proves | How it lies |
|---|---|---|---|
<meta name="generator"> | <meta name="generator" content="WordPress 7.2-alpha-63618" /> | Someone deliberately declared the tool, often with a version | It’s one line of HTML anyone can delete; plugins inject their own |
| HTML fingerprints | wp-content/, _next/static/, __NUXT__, astro-island, data-v-8f3a1c | The build output has a recognizable shape | Frameworks change their output between majors; the marker can appear in prose |
| Script URLs | <script src="https://cdn.usefathom.com/script.js"> | A third-party script is loaded from that host | Self-host or proxy it and the URL signal vanishes entirely |
| Response headers | server: Vercel, x-powered-by: Next.js, cf-ray, x-vercel-id, x-nf-request-id | Who answered this connection | That’s the edge, not your origin — a CDN answers first and may strip everything behind it |
| Cookies | set-cookie: laravel_session=... | Some session middleware ran | Cookie names are config values, not constants |
| JS globals (extension-only) | window.__NUXT__, window.Shopify | The code genuinely executed | Needs a real browser; a server-side fetch never sees it |
Those last two rows are the dividing line in this whole category. A browser-extension detector reads the live page after JavaScript has run and can inspect window. A server-side detector — including ours — fetches the HTML over HTTP and reads the bytes. Same signals in the first four rows, completely different coverage in the last two.
False positive #1: the inference chain
Detectors don’t just match; they infer. __NEXT_DATA__ implies Next.js, Next.js implies React, React implies a build step. Each hop is usually right and occasionally catastrophic, and the output presents hop three with exactly the same confidence as hop one.
The subtler failure is a rule that was never specific in the first place. Two real examples from the pattern file our own detector shipped with until this post was written, quoted verbatim:
// Tailwind CSS
/class="[^"]*(?:flex|grid|space-[xy]|bg-|text-|rounded-|px-|py-)/i
// Bootstrap
/class="[^"]*(?:container|row|col-)/i
Read those as a stranger would. The first fires on any class attribute containing the substring flex — which includes Bootstrap’s own .d-flex and .flex-row utilities. The second fires on container — which is a real Tailwind utility, documented as setting “the maximum width of an element to match the min-width of the current breakpoint.”
So I ran our detector against eight sites whose stacks are not in dispute. Bootstrap was reported on all eight. It was actually present on one. The other seven matched purely on the class-name heuristic — including the Tailwind documentation site. The Tailwind rule fired on all eight too, and happened to be right more often only because Tailwind is genuinely everywhere now.
Substring matching produces stranger results than that. On laravel.com, our detector reports Ruby on Rails, because the rule is /csrf-token/i and Laravel’s docs tell you to write <meta name="csrf-token" content="{{ csrf_token() }}"> — which is also, exactly, what Rails’ csrf_meta_tags emits. Two unrelated frameworks converged on the same meta tag name a decade ago and no regex can separate them.
Same page, same run: our detector reports Segment, because the rule is /analytics\.js/i and the page loads https://analytics.ahrefs.com/analytics.js. That’s Ahrefs, not Segment. And on tailwindcss.com it reports Webflow as the CMS, because Tailwind’s marketing page mentions webflow.com in a customer list. The tool cannot tell a dependency from a name-drop.
Every rule quoted above is now gone. Tailwind is detected by its responsive-variant syntax (md:flex, a colon in a class name is not something other frameworks emit) or its CDN; Bootstrap by d-flex, col-md-6, navbar-expand-* or the stylesheet URL; Rails by the csrf-param meta that Laravel never writes; Segment, Hotjar, jQuery and the rest only by the host of a real <script src>; and “Next.js therefore React” is reported as an inferred result, drawn dashed in the UI, instead of a React sighting. Re-running against ten sites with known stacks: Bootstrap fires on getbootstrap.com, and on webflow.com — whose own markup uses Bootstrap’s col-md-6 col-sm-12 grid names, a collision no regex can resolve from outside. Everything else lines up with what the sites actually ship. The lesson is not that our rules were uniquely bad — it’s that this is what a fingerprint database looks like before someone checks it against pages it should not match.
The structural version of this bug: a headless setup where WordPress is the admin backend and a Next.js frontend renders the site. Every visible signal says Next.js. WordPress is right there, running, invisible.
False positive #2: the CDN is not the stack
Headers describe the machine that answered you. Our detector reports both Cloudflare and Fastly for getbootstrap.com — cf-ray from one, x-served-by from the other — which is a correct reading of a layered edge, and tells you nothing about the origin behind it.
Cloudflare’s docs describe CF-Ray as “a hashed value that encodes information about the data center and the visitor’s request.” That’s a fact about Cloudflare’s network, full stop. Put Cloudflare in front of a Rails app and server: cloudflare is what the world sees.
False negatives: the things that were never in the response
The other failure mode is quieter, because the tool just prints a shorter list and nobody notices what’s missing.
Frameworks move their markers. __NEXT_DATA__ is a Pages Router artifact. Next.js’ own docs are explicit: the serialization “only affects the Pages Router, where Next.js serializes page data as __NEXT_DATA__ JSON… The App Router streams data via Server Components and is not affected.” Fetch nextjs.org today and there are zero occurrences of __NEXT_DATA__ and 125 of _next/static. Our React rule keys off data-reactroot, __NEXT_DATA__ and react.production — so nextjs.org, a React site, is detected as Next.js and not as React. A ruleset written in 2022 silently rots.
Cookie names are configuration. Laravel 9’s config/session.php defaulted the cookie to Str::slug(env('APP_NAME', 'laravel'), '_').'_session' — that’s laravel_session. Laravel 12 changed the separator: Str::slug((string) env('APP_NAME', 'laravel')).'-session' — that’s laravel-session, with a hyphen. A rule matching laravel_session now misses every modern default install, and every install that set APP_NAME at all.
Minified bundles erase URLs. A rule like /jquery(?:\.min)?\.js/i needs the filename to survive. Bundle jQuery into app.4f2c9a.js and there is nothing left to match.
JavaScript-rendered content doesn’t exist over HTTP. A single-page app can ship an empty <div id="root"> and build the whole page client-side. A server-side fetch gets the empty div.
And some things are genuinely unknowable from outside, no matter how good the tool:
- Backend language and framework, once
X-Powered-Byis off and the HTML is generic. - Database. Nothing about Postgres versus MySQL reaches the wire.
- Hosting, when a CDN terminates the connection.
- Versions, except where something volunteers one — which is exactly why the
generatortag is worth deleting.
What happened to Wappalyzer
Worth knowing if you’re searching for a BuiltWith alternative and finding dead links. Wappalyzer was the open-source reference implementation of this whole category — a GPL ruleset of regexes in src/technologies/, community-contributed for years.
In August 2023 the public repositories came down. The Hacker News thread “Wappalyzer removes public access from all repositories” is dated August 23, 2023, and notes the npm package was deprecated alongside it. The ruleset went private with the product.
Forks of the last public commit continued. enthec/webappanalyzer states its purpose plainly — “This project is a continuation of the iconic Wappalyzer that went private in August 2023” — and keeps the JSON fingerprint format, matching on headers, cookies, meta tags, script sources, DOM selectors, JS properties, DNS records and robots.txt. That file format is still the de-facto standard for anyone building in this space.
How to stop advertising your stack
Four changes, in order of how much they give away.
Delete the generator tag. WordPress hooks wp_generator() onto wp_head, and one line in your theme’s functions.php or a small plugin removes it:
remove_action( 'wp_head', 'wp_generator' );
That’s not cosmetic. wordpress.org’s own news site currently ships <meta name="generator" content="WordPress 7.2-alpha-63618" /> — a precise build identifier, handed to every scanner on the internet.
Trim the Server header. Apache’s default is ServerTokens Full, which per the core docs sends “Server: Apache/2.4.2 (Unix) PHP/4.2.2 MyMod/1.2” — OS and module versions included. ServerTokens Prod reduces it to Server: Apache. On nginx, server_tokens defaults to on and “enables or disables emitting nginx version on error pages and in the Server response header field”; set it off.
Drop X-Powered-By. PHP’s expose_php defaults to "1" and “exposes to the world that PHP is installed on the server, which includes the PHP version within the HTTP header.” It’s php.ini-only — no ini_set(). Next.js adds the header too; its docs give you poweredByHeader: false. For anything else, strip it at the web server with mod_headers:
ServerTokens Prod
Header always unset X-Powered-By
server_tokens off;
proxy_hide_header X-Powered-By;
Be honest about what this buys you. Apache’s documentation says it better than I would, in the ServerTokens entry itself:
Also note that disabling the Server: header does nothing at all to make your server more secure. The idea of “security through obscurity” is a myth and leads to a false sense of safety.
That’s correct, and it isn’t the whole story. Mass scanners are cheap and dumb: they read a version string, match it to a CVE list, and queue a payload. Removing the version doesn’t stop a targeted attacker who will fingerprint you five other ways — it removes you from the bucket that gets sprayed. Do it because it reduces noise, patch because that’s what actually protects you. Check what you’re leaking with the HTTP header analyzer and the meta tag analyzer, and while you’re editing response headers, a CSP built with the CSP builder is worth more than any of this.
How our detector works, exactly
No black box. The technology detector posts your URL to our proxy at api.lunchboxhands.com — /fetch for the HTML, /headers for the response headers — and the browser then runs 33 regex patterns across eight categories (CMS, Framework, Analytics, Tag Manager, CDN, Fonts, JavaScript Library, CSS Framework) against the raw HTML, the list of <script src> URLs, the <meta> tags and the header map.
What that means concretely:
- It does not execute JavaScript. No
windowinspection, no client-rendered DOM, no globals. A framework that leaves no server-rendered marker is invisible to it. - The proxy identifies itself as
User-Agent: LunchboxHandsBot/1.0. Sites that vary their output by user agent will hand it something different from what you see. - Header detection follows redirects up to ten hops and reports headers from the final response.
- Script rules see only script URLs.
analytics.jsin a paragraph, or on an unrelated host, doesn’t fire a vendor rule; a vendor’s CDN in asrcattribute does. - Inferred results are labelled. React shown because Next.js was found appears dashed and marked “inferred”. Hover any result to see the exact string or header that matched it.
- The CSS framework rules were the worst offenders until this post was written, as demonstrated at length above. They now key on syntax only one framework emits, but a CSS framework result is still the softest signal on the page.
- It reads what the CDN says.
cf-raymeans Cloudflare is in front of the site — nothing about the origin.
What it’s reliable for: generator tags, distinctive build-output paths (wp-content, _next/static, _nuxt/, astro-island), third-party script hosts loaded from their own CDN, and platform headers like x-vercel-id and x-nf-request-id. Those are high-signal and hard to fake by accident.
What to do with the answer
Treat a detection result as a hypothesis with a confidence level you assign yourself. A generator tag and a platform header are close to evidence. A class-name match is a guess. An inferred dependency — React because Next.js — is a guess about a guess.
If the answer matters, corroborate across signals. Run the technology detector for the pattern matches, the HTTP header analyzer to see the raw headers without interpretation, and the SEO analyzer for the full picture of what the page is actually shipping — all three are in our roundup of free SEO tools that skip the signup. Three views, and none of them will pretend to know what database you’re running.