Brand impersonation: a sign-in page that is not the brand's
A 'Sign in with Google' control pointing at a host that is not Google is a phishing pattern. squirrel checks where credential controls actually go.
What brand impersonation is
Brand impersonation, in this context, is a page on your own site that presents another company’s sign-in or booking surface while sending the credentials somewhere that company does not own. It is the shape of an injected phishing kit: the attacker uses your domain’s reputation to make the fake login look trustworthy.
A legitimate integration always targets the provider’s own host, accounts.google.com for Google or calendly.com for Calendly. Merely naming a brand is not impersonation, and neither is your own app’s generic sign-in form. The defect is the mismatch between what the control says and where it points.
What squirrel checks
The rule runs on every crawled page and emits at most one check named brand-impersonation. It fires only when all of the following hold on the same page: the page has a credential affordance, meaning an input[type="password"], sign-in copy in the title or body text, or a link whose own label says sign in, log in or login (a link labelled only verify or continue with is not enough by itself); the text matches a brand’s lexicon; the text also matches one of that brand’s action cues; and a credential control whose own label names that brand points at a host that is neither the brand’s legitimate host nor the site’s own registrable domain.
- Info when this is the only integrity signal on the page. Message:
Possible Calendly brand impersonation (single signal — review). - Fail when at least two distinct integrity signals fire on the page. Message:
Likely phishing kit impersonating Calendly (3 corroborating integrity signals).
Six brands are covered: Google, Microsoft, Calendly, DocuSign, ClickFunnels and Kajabi, each with its own lexicon, action cues and legitimate hosts. The credential surface is deliberately narrow: form actions on forms that contain a password field, and controls whose own label matches sign in, log in, login, continue with or verify. A control is a link, a <button>, an <input type="submit">, button or image, or an element with role="button", and its label is read from its text and from aria-label, title, value and the alt of a logo image inside it, so a label that exists only in an attribute counts. A button’s destination is its formaction or its form’s action; a button with neither has none to judge. Google’s own wording (Continue with Google, Log in with Google) and the Microsoft equivalents count as sign-in labels. Labels are normalised before matching: fullwidth and styled letters fold to ASCII (Unicode NFKC), Cyrillic, Greek and Armenian look-alikes for Latin letters map to the Latin letter (including U+03F2, U+0269, п and г), invisible characters and the braille blank (U+2800) are removed, and in a word that mixes letters and digits a 0, 3 or 5 reads as o, e or s and a 1 as i or l, whichever makes a word of the cue (S1gn 1n w1th G00g1e); a number on its own (1995, 10) is left alone, and a label is read as written as well as folded, so a cue that has a digit in it (Office365 login) still matches. The fold is bounded: a label is folded to its first 16,000 characters, every control folds the first 300 characters of its attributes and of its text outside any page budget (where short cue labels live), the characters past them come from a page pool of 2,000,000, and a control whose label or attribute was not folded in full is counted in controlsUnread (the label as written is always read, and a cue within the first 300 characters of a label always folds; a cue past that can be hidden once the 2,000,000-character page pool is spent, and the control is counted unread. A page of long digit-heavy fillers can use up the pool but cannot hide a short swapped-digit cue. Measured cost: about 3.5 s for a 39 MB page of 60,000 controls) (a swapped-digit cue is read in an attribute’s later windows too). Limits: a glued token that needs an l and an i together (signinwithg00g1e) is read with i and is not a cue, and page text is not digit-folded, only the text and attributes of controls. The look-alike table is a hand list, not the whole Unicode UTS #39 skeleton: the cues are ASCII words, so only the characters that can stand for one of their letters matter, and a look-alike nobody has listed is a way past the match (the table grows with the evasions found). Text spliced into a label does not hide it: <script>, <style> and <noscript> content is not text, and the label is read both with and without text hidden by an inline style (display:none, visibility:hidden, zero font size, zero opacity), a class a <style> block hides, hidden or aria-hidden, and with and without a space at each element boundary and with no spaces at all, so Sign<br>in with Goo<i>gle</i> reads as the phrase it is. Links and buttons inside a <template> (nested ones too), a button tied to its form by form=, a label named by aria-labelledby, and a logo alt any number of levels inside its link (up to 64) are read. The reading is bounded by work, so a hostile page cannot make the rule slow, and every bound that can cut a relevant read is reported (see the end of this paragraph). A link whose destination, as a browser resolves it (tabs and newlines inside the URL are removed first and a backslash is a slash, both when deciding whether it is the page’s own site and when ranking which controls get the longer read), is the page’s own site is skipped unless it has an attribute that could carry a cue (aria-label, title, aria-labelledby, a logo alt). A <base href> that is not a plain local path (the first one with an href, as a browser reads it) moves where relative links resolve: then no link counts as the page’s own site by its path alone, every one is read, and a relative link or form action is judged by the base’s host (an empty form action still goes to the page itself, an empty link href goes to the base, and a relative iframe src in fake-auth-overlay resolves against the base too; a <base> inside <svg>, <math>, <noscript> or <template> is not the document’s base, except one inside <svg><foreignObject>, which is an HTML element; which base wins is decided in one linear pass in document order with no depth bound, so how deeply a <base> is nested never changes the answer). Under a moved base every link is ranked like an off-site one, so a flood of other nodes cannot push a local-looking link out of the longer read. A control with no destination (a script-driven button outside any form) is not read either. Of the rest, buttons and links with a sign-in path come first, then links to other hosts, and at most 60,000 are read (a flood of off-site links cannot push a real control out); a destination is judged as a browser reads it (tabs and newlines removed, \\ as /), and a sign-in path is looked for in its first 8,192 characters; templates (nested ones too) are read in place, at most 5,000 of those with an element inside; an aria-labelledby target is read once however many controls name it, at most 256 nodes of it, and at most the first 4 targets of a control are used; an attribute is normalised (padding spaces and zero-width characters removed) over its first 16,000 characters and read in overlapping 300-character windows with a stride of 150, so a phrase crossing a window edge is still read. A window edge that falls inside a word is marked, so a window cut out of Redesign in with Google is not read as sign in with Google. The first window of every attribute is always read. The windows after it have their own budget, separate from the node budget below: they are read only where the start of a sign-in or brand phrase is in them (the words come from one list of auth wording and the rule’s own lexicon, the same list the label patterns are built from), and at most 40 per attribute; over that, windows that name a brand (google, microsoft, …) are kept before windows that only repeat a sign-in word. A control that wraps several logos reads the alt of its first four in full and the next ones for their first 300 characters, and keeps at most 100 in all; the control is counted as not read in full when a later alt is longer than 300 characters or when there are more than 100. Every control with long attributes keeps two of its cue windows outside the page budgets (its first and its last, across all its attributes), so no run of earlier controls can leave a later one with only the first window of each attribute. That floor is extra work bounded by the page: up to two windows of 300 characters per control, about 120,000 windows on a page of 60,000 controls. Beyond that floor the page has 20,000 windows to spend: half in order (controls with a sign-in or brand word in an attribute first, then document order, so the first controls of the page are always read in full) and half shared fairly over the controls that are still cut, those with no cue word as written (an obfuscated spelling) first. A control is counted as not read in full when its attribute extends beyond the 16,000-character bound (raw or normalised) or when a cap or the budget cut windows from it, and the check is emitted with details.controlsUnread even when nothing matched. A control counts once, however many of its bounds it hit (its attributes, its window caps, its text read); controls cut by the prepared-controls cap or the logo-alt cap are counted too; templates past the 5,000-template limit are counted separately, in details.templatesUnread. That count is the limit: a page with more cue-bearing windows than the budget holds reports it instead of reading the rest. A control’s attributes are read in one pass, each by a length test first, so the href and the cue attributes (aria-label, title, aria-labelledby, value, alt) are found wherever they sit among up to 4,096 attributes, and a second pass reads the attributes past the 4,096th for the same names, charged to the page’s attribute budget (padding a control with about 20 KB of valueless attributes does not push its href or cue past the read); a control whose remaining attributes the budget cannot cover is counted as not read in full, and a link whose href or cue attribute may lie past what was read is counted, never skipped as having no destination or no cue. An aria-labelledby value is read from its first non-space character, up to 16,000 characters; an id left past that is counted in details.controlsUnread. The page spends at most 2,000,000 work units (one per control and one per attribute looked at) finding its controls’ destinations; a further budget of 1,500,000 units (one per prepared control and one per aria-labelledby id) is a backstop that ordinary pages cannot reach, since at most 60,000 controls are prepared at 5 units each. The text of the aria-labelledby targets is read at most 300,000 visits in all, plus at most 255 (a read stops at 256 visits and the limit is checked before it starts; three quarters of the 400,000 node visits below), checked before each read, so thousands of distinct targets cannot spend more; a control whose target is not read is counted. A control the budgets do not cover is counted in controlsUnread and the rule reports it, never a clean result. The budgets bound the work a page can cost, not the time: a rule still runs against a wall-clock budget, and a busy machine can spend it. Limits that are not counted: a cue in an aria-labelledby target past its first 300 characters or 256 node visits is not read, and a button with no destination (a script-driven onclick outside a form) is not judged. The page as a whole has a fixed amount of reading to spend (400,000 nodes), shared fairly: each control’s allowance is what is left divided by the controls still to read (at least 4 nodes, at most 256). The most relevant controls (a sign-in attribute, then buttons and links to another host or a sign-in path) also get a longer read from a separate budget. Looking up a control’s form or logo is memoised along the path. A phrase is matched as whole words with a space optional between its words, so a label split by markup (Sign<br>in with Goo<i>gle</i> to continue) is the phrase, while Redesign in with Google Workspace or Login with GoogleMail is not a sign-in with Google. A control that is relevant and still could not be read in full (a label of thousands of nested elements, more than 60,000 candidates, an aria-labelledby list or an attribute longer than what is read) is counted, and when no impersonation is found it is reported: with no impersonation found, the rule emits an info brand-impersonation check with details.scanTruncated and details.controlsUnread instead of nothing. A CSS rule too large to read (a body past 256 KB, or a selector part past 2,048 characters) that could hide a class or id the page uses can hide text in a sign-in label, so when the page has controls the scan carries details.hiddenCssTruncated: on the info note when no impersonation is found, and in the details of a finding. The note is also emitted on a clean page that merely has such a rule and a sign-in label (a 300 KB class rule and a same-site aria-label="Sign in"): it says the read was incomplete, not that anything is wrong. Templates with content past the 5,000-template limit are counted separately, in details.templatesUnread, and named as templates in the message. A footer link to an unrelated third party is not read as a credential destination. Body text is read with a space at every element boundary as well as without, so <h1>Welcome</h1><a>Sign in with Google</a> is seen as sign-in copy whether or not the markup has whitespace between its elements. Body text and each link’s label are read with invisible characters (a soft hyphen, zero-width spaces) removed and every run of whitespace as one space, so a newline, , a <br> or a zero-width space inside Sign in with Google does not hide it. A “Sign in with Google” or “Sign in with Microsoft” control that goes to a hosted identity broker’s authorize endpoint (https://<tenant>.auth0.com/authorize, https://<tenant>.okta.com/oauth2/.../v1/authorize, https://<tenant>.auth.<region>.amazoncognito.com/oauth2/authorize, https://<project>.supabase.co/auth/v1/authorize, https://<project>.firebaseapp.com/__/auth/handler) is a real integration and is not reported. The exemption is the endpoint, the provider and, where the broker has tenants, the tenant: those hosts are open to anyone who registers a tenant, a tenant controls its hosted login page, and the link is written by whoever wrote the page, so neither the authorize path nor a hint in the URL proves a real integration. For Auth0, Okta and Cognito the tenant (the FIRST host label, with only the family’s own shape after it: .auth0.com with an optional region, .okta.com (also oktapreview.com, okta-emea.com), .auth.<region>.amazoncognito.com) must be the audited site’s name: exactly (example.auth0.com, example.okta.com on example.com), which needs no hint, or the name plus a hyphenated environment suffix from a fixed list (prod, production, dev, staging, stage, test, qa, uat, preview: example-prod.okta.com), which also needs the URL to name the provider the label does in its query (connection=google-oauth2 or windowslive for Auth0, identity_provider=Google for Cognito, a non-empty idp for Okta, which names a configured identity provider by an opaque id). Any other suffix or prefix (example-login, example-sso, examplecorp, example-eu), a deeper label (evil.example.auth0.com), a look-alike and a name shorter than three letters are never spared (a short site name matches too much); the name is the registrable name of the page’s own host or of its canonical or og:url host. A foreign tenant, a bare authorize URL on a tenant that is not the site’s, and one naming another provider are judged like any other off-brand host. A hint is read from the first 8,192 characters of the query; one pushed past that is not found, so the link is reported (never spared). Residual: a tenant is a free name, so an attacker who registers the audited site’s exact tenant name before the site does (an unused acme.auth0.com for acme.com) is spared; no check on the URL can close that, only the site owning its tenant. For Supabase (provider=google or azure) and Firebase (providerId=google.com) the endpoint goes straight to the provider with no tenant-controlled login page, so the named provider is the whole condition. A sign-in through a differently named tenant is reported as a single signal (an info check). Any other path on these hosts is judged as before, and no other brand is spared by them. A control labelled with a brand that goes to another registrable domain which is not one of these endpoints (another identity service such as WorkOS or Zitadel, or your own sign-in on a different domain) is a single signal: an info check, never a failure unless a second integrity signal corroborates it. The brand’s own hosts are an explicit list, not every host under google.com or microsoft.com: Google’s are accounts.google.com, accounts.youtube.com (Google’s single sign-on host), myaccount.google.com and the /accounts path of google.com and www.google.com; Microsoft’s are login.microsoftonline.com, login.microsoftonline.us, login.microsoftonline.de, login.partner.microsoftonline.cn, login.chinacloudapi.cn, login.windows.net, login.live.com, login.microsoft.com, account.live.com and account.microsoft.com, and the tenant hosts <tenant>.b2clogin.com and <tenant>.ciamlogin.com only on a path that contains /oauth2/authorize or /oauth2/v2.0/authorize (the tenant and policy come before it, also in the older /tfp/<tenant>/<policy>/ form) and only for the audited site’s own tenant: the tenant must be the site’s name, or its name plus an environment suffix, by the same rule as the identity brokers below (a tenant is owned by whoever registered it, so evil.b2clogin.com/x/oauth2/v2.0/authorize is not spared). Residual, the same trade-off as for the identity brokers: a tenant is a free name, so an attacker whose own domain shares the audited site’s registrable name (evilcorp.example registering evilcorp.b2clogin.com) is spared on a page of that domain, which only the site owning its tenant can close. Hosts that hold user content or send a visitor on (sites.google.com, docs.google.com, script.google.com, drive.google.com, forms.google.com, groups.google.com, storage.cloud.google.com, lookerstudio.google.com, colab.research.google.com, calendar.google.com, the translate.google.com proxy, the google.com/url and google.com/amp/s redirectors, forms.microsoft.com, answers.microsoft.com, www.microsoft.com) are not the brand’s sign-in hosts: a sign-in control to one of them is judged like any other off-brand destination. A link to one of them that is a single signal is an info check, so a benign link such as a “Sign in with Google” label that points at drive.google.com is now reported at that level. Known limits, tracked as public issues: a sign-in to a Supabase or Firebase project’s authorize endpoint is spared though anyone can register a project there, and a page hosted on a user-content host (sites.google.com, forms.microsoft.com) whose sign-in control goes to another host of the same registrable domain is spared as the page’s own site (#1418); a cue after more than 16,000 characters of an attribute label, or in windows the page budget cut, is not read and the control is counted (#1424); a style attribute is read to 1 MB for what it hides (once per element); a control with a longer one is counted in controlsUnread (#1423). Sibling subdomains count as the site’s own surface, resolved through the Public Suffix List, so a link from www.acme.com to app.acme.com is spared.
The four page-level integrity rules share one detector module and each computes the full signal set for its own page. Escalation to fail requires two distinct signals from brand-impersonation, obfuscated-script, fake-auth-overlay and seo-doorway.
How to fix it
<a href="https://accounts.google.com/o/oauth2/v2/auth?client_id=...">Sign in with Google</a>Point the control at the provider’s own host. If you did not build the page, treat the site as compromised: remove the page, look for recently modified files and unexpected CMS entries, rotate credentials and read the server access logs.
An OAuth flow proxied through your own authentication service is not flagged, because the destination shares your registrable domain. A flow proxied through a separate vendor domain will be flagged as a single signal, which is info rather than a failure.
| Rule ID | integrity/brand-impersonation |
| Category | Site Integrity |
| Scope | Page |
| Severity | warning |
| Weight | 8/10 |
Enable / disable
Disable this rule
[rules]
disable = ["integrity/brand-impersonation"]Disable all Site Integrity rules
[rules]
disable = ["integrity/*"]Enable only this rule
[rules]
enable = ["integrity/brand-impersonation"]enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.
Related rules
- integrity/fake-auth-overlay: the iframe version of the same credential grab.
- integrity/obfuscated-script: the second signal that turns this finding into a failure.
- integrity/orphan-page: whether the page is linked from anywhere on your site.
- integrity/kit-signature: threat-intel signatures for the same kits, when the intel feature is on.
Site Integrity findings ship in every audit next to the SEO, performance and agent experience rules. See Website security scan with AI for how an agent works through a report.
Glossary: Hacked site signals.
References
- Social engineering (phishing and deceptive sites), Google Search Central
- Hacked content, Google spam policies
Check your site
Run squirrel audit https://example.com and open the Site Integrity section of the report. Each impersonation is listed by page with the brand and the off-brand host it targets. Signed-out local audits need no account and cost nothing. Signed-in audits spend credits.