Search Console says "Possible phishing detected on user login" on a site with no login
Symptom
Search Console, Security Issues, on a static site of mine:
Security issues
1 issue detected
Possible phishing detected on user login
The browser will show pop-up warnings when users enter saved login
credentials into some pages on your site
Sample URLs: N/A
The site is a generated static blog. No accounts, no login page, no registration, no comments, no
newsletter signup, no <input type="password"> anywhere in the output, and none of those ever existed.
There is nothing on it that a user could enter login credentials into, which makes the message read like
either a false positive or — the reading that ruins your evening — evidence that someone is serving a login
page from my domain that I have never seen.
The Sample URLs: N/A is the part that makes it hard: the usual triage loop is “open the flagged URL, look
at what Google looked at”, and there is no flagged URL to open.
What it is not
This is not a Safe Browsing blocklisting. Those are different issue types (“Deceptive pages”, “Harmful downloads”), and they come with visible consequences: the red Deceptive site ahead interstitial in Chrome, warnings on links from Search, and usually sample URLs. None of that was happening. The site loaded normally in a clean profile and still ranked.
“Possible phishing detected on user login” is the Chrome password-reuse warning signal — Search Console Help documents it under Password Reuse Warning in Chrome. The mechanism is client-side: when a user types a password that Chrome has saved for some other site into a page, Chrome consults its local Safe Browsing model, and if that model scores the page as phishy it shows the “Change your password” pop-up. The signal that reaches Search Console is that ping, not a crawl result. That is exactly why there are no sample URLs — Google is not telling you which page was scored, because the report did not come from fetching your pages, it came from a browser on someone’s machine.
So the warning is about a client-side score, not about a verdict on your content. That does not make it harmless, but it does change the question from “what did Google find on my site” to “is there anything on my site that can accept credentials at all”.
What I measured
Three checks. For a static site they are cheap and, together, conclusive.
1. Confirm there is no blocklisting. The Transparency Report has a status endpoint the site status page itself uses:
curl -s "https://transparencyreport.google.com/transparencyreport/api/v3/safebrowsing/status?site=example.com"
The response begins with the XSSI guard )]}' and then a JSON array:
)]}'
[["sb.ssr",1,false,false,false,false,false,<timestamp>,"example.com",false]]
The second element is the status code, and it is worth calibrating it against a host whose state you
already know rather than guessing. 1 is what the UI renders as “No unsafe content found”; 3 is
“No available data”, which is what you get for a host with no history at all. The flagged property
returned 1. No blocklisting, no interstitial — consistent with what the browser was showing me.
2. Confirm the served HTML is the HTML I built. A compromise on a static site means the served bytes
differ from the build output. So diff them. The trap is that on Cloudflare they always differ a little: the
edge injects an inline script that defines window.__CF$cv$params and appends
/cdn-cgi/challenge-platform/scripts/jsd/main.js inside a 1x1 hidden iframe. That one block is the only
legitimate delta. Anything else — a stray <script src>, an extra form, a hidden div — is an injection.
Reading a whole-page diff for that is unpleasant, so I dumped the opcodes instead and looked only at the
inserted regions:
import difflib, pathlib, urllib.request
local = pathlib.Path("public/index.html").read_text()
served = urllib.request.urlopen("https://example.com/").read().decode()
sm = difflib.SequenceMatcher(None, local, served, autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
continue
print(tag, (i1, i2), (j1, j2))
print(" local :", local[i1:i2][:160].replace("\n", " "))
print(" served:", served[j1:j2][:160].replace("\n", " "))
Run that over the handful of template-distinct pages (home, a post, the list page, a tag page). Every insertion on my site was the Cloudflare snippet; nothing was replaced or deleted.
3. Inventory the surface. The real question is whether any page can accept input that looks like credentials, so count it across the whole site instead of the pages I happen to remember:
curl -s https://example.com/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<\/*loc>//g' \
| while read -r url; do
html=$(curl -s "$url")
printf '%s forms=%s inputs=%s pw=%s\n' "$url" \
"$(printf '%s' "$html" | grep -c '<form')" \
"$(printf '%s' "$html" | grep -c '<input')" \
"$(printf '%s' "$html" | grep -c 'type=.password')"
printf '%s' "$html" \
| grep -oE '(src|href|action)="https?://[^/"]+' \
| grep -oE 'https?://[^/"]+' >> /tmp/origins.txt
done
sort -u /tmp/origins.txt
Result: 22 URLs, zero password inputs, exactly one <form> element, and a single third-party origin (the
AdSense loader). Nothing that submits anywhere, nothing that could carry a credential off the page.
That single <form> is the interesting part. The posts list had a filter box:
<form class="filter" role="search" onsubmit="return false">
It filters an already-rendered list of posts in JavaScript, has no action, and its submit handler exists
purely to stop the browser from navigating. It never submitted anything anywhere. The <form> element was
earning nothing — it was there because a text input with a label looks like a form when you write it.
The fix
Two changes, both deployed and verified.
Serve zero form elements. The filter is a div now, with the same label, the same input and the same
live result count:
<div class="filter" role="search">
<label for="q">Filter posts</label>
<input type="search" id="q" autocomplete="off" placeholder="type to filter">
<span class="count" aria-live="polite"></span>
</div>
Nothing was lost. role="search" keeps the landmark semantics a screen reader needs, autocomplete="off"
keeps the browser from offering saved values, and the hidden attribute I use to hide filtered-out entries
works exactly the same on a div container as it did inside a form. The onsubmit="return false" hack is
gone because there is no longer a submit event to cancel.
Make credential submission impossible even from markup I did not write. On Cloudflare Pages, one line
in static/_headers:
/*
Content-Security-Policy: form-action 'none'; base-uri 'none'; object-src 'none'
form-action 'none' means the browser will refuse to submit any form from any page of the site — including
a form injected later by a compromised dependency or a rogue build. base-uri 'none' stops a <base> tag
from re-pointing relative URLs, and object-src 'none' removes the plugin surface.
Note what is not in there: no script-src. A script policy is the standard way to accidentally break an
ad loader or an analytics snippet, and it needs real testing against every third-party origin. form-action 'none' costs nothing on a site with no forms and closes the exact hole the warning is about.
After deploying, verify in the browser — not in the config:
document.forms.length // 0
Then check the response headers for the CSP line, confirm the third-party loader still initialises (it
does; no script-src directive was added), and press Enter in the filter box to confirm it no longer tries
to navigate.
Why it happens and what to remember
The remediation path for this issue type is Security Issues → Request Review, with a factual description of what you verified and what you changed. That is the only mechanism available, because you cannot see the offending URL and therefore cannot “fix the page”. The review request is where you put the evidence: no login exists, the served HTML matches the build, the site serves zero forms, and submission is blocked by policy.
Worth knowing before you assume a hack: a plausible contributor to a client-side phishing score is
writing about authentication. Pages that discuss sign-in flows, tokens, cookies and credential handling
contain exactly the vocabulary a phishing classifier keys on, and on a young domain with no reputation
there is little else for the model to weigh against it. Combine that with a page containing a labelled text
input inside a <form>, and you have something that pattern-matches a login screen without being one.
The transferable lesson is smaller than the scare: know which Search Console security issue types are
blocklistings and which are client-side signals, because the triage is completely different. And on a
static site, “zero forms plus form-action 'none'” is a free invariant — nothing I build needs a form, so
anything that tries to submit is by definition not mine.