Phishing remains one of the most effective attack vectors in 2026 — not because defenders are incompetent, but because attackers have gotten very good at making malicious URLs look legitimate. A URL like secure-login.paypal-account-verification.com/oauth passes a casual glance. Your users won't catch it. A well-built detector can.
This post walks through building a practical phishing URL detector in Python: feature engineering, training a classifier, and adding a language model escalation layer for ambiguous cases.
Why URL-based detection works
Most phishing URLs share structural patterns that legitimate URLs avoid. The domain has an unusual character distribution. The path is unusually long. The hostname contains brand names (paypal, netflix, google) in odd positions — a subdomain or path component rather than the registrable domain itself.
These signals aren't perfect individually, but they compose well into a classifier. The goal isn't 100% accuracy — it's adding a reliable, low-latency layer to your security stack that flags URLs worth inspecting before a user clicks.
Feature engineering
Before touching any model, decide what you're measuring. Here are the features that consistently show up in practice:
- Domain length: phishing domains tend to be longer
-
Dot count in hostname:
mail.secure.paypal.verification.comhas more dots thanpaypal.com - Digit ratio: more digits often signals auto-generated hostnames
-
Presence of IP address:
http://192.168.1.1/loginis a classic tell - Use of URL shorteners (bit.ly, tinyurl, etc.)
-
Suspicious keywords in path/query:
login,verify,account,secure,update - Entropy of the domain: high Shannon entropy suggests algorithmically generated names
import re
import math
from urllib.parse import urlparse
from collections import Counter
SUSPICIOUS_KEYWORDS = {"login", "verify", "account", "secure", "update", "confirm", "bank"}
URL_SHORTENERS = {"bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly"}
def shannon_entropy(s: str) -> float:
if not s:
return 0.0
counts = Counter(s)
total = len(s)
return -sum((c / total) * math.log2(c / total) for c in counts.values())
def extract_features(url: str) -> dict:
parsed = urlparse(url if url.startswith("http") else f"https://{url}")
hostname = parsed.hostname or ""
path = parsed.path or ""
full = url.lower()
digits = sum(c.isdigit() for c in hostname)
return {
"url_length": len(url),
"hostname_length": len(hostname),
"dot_count": hostname.count("."),
"digit_ratio": digits / len(hostname) if hostname else 0,
"has_ip": bool(re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", hostname)),
"is_shortener": hostname in URL_SHORTENERS,
"keyword_count": sum(kw in full for kw in SUSPICIOUS_KEYWORDS),
"entropy": shannon_entropy(hostname),
"path_length": len(path),
"has_at_sign": "@" in url,
"subdomain_count": max(0, len(hostname.split(".")) - 2),
"uses_https": parsed.scheme == "https",
}
Run this against a few URLs to sanity-check — paypal.com should score very differently from secure-paypal-login.verification-center.com.
Training a gradient boosting classifier
A gradient boosting model (scikit-learn's HistGradientBoostingClassifier) works well here: it handles the mix of binary and continuous features, trains fast, and is interpretable enough that you can explain why it flagged a URL.
For training data, the PhishTank dataset (phishing URLs) and the Tranco top-1M (legitimate URLs) are the standard sources. Here's the training scaffold:
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import joblib
def load_dataset(phishing_path: str, legit_path: str) -> tuple[pd.DataFrame, pd.Series]:
phishing_urls = pd.read_csv(phishing_path, names=["url"])["url"].dropna().tolist()
legit_urls = pd.read_csv(legit_path, names=["url"])["url"].dropna().tolist()
records = []
for url in phishing_urls:
feats = extract_features(url)
feats["label"] = 1
records.append(feats)
for url in legit_urls:
feats = extract_features(url)
feats["label"] = 0
records.append(feats)
df = pd.DataFrame(records)
return df.drop("label", axis=1), df["label"]
def train(phishing_path: str, legit_path: str, model_path: str = "phishing_model.joblib"):
X, y = load_dataset(phishing_path, legit_path)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
clf = HistGradientBoostingClassifier(max_iter=200, learning_rate=0.05, max_depth=6)
clf.fit(X_train, y_train)
print(classification_report(y_test, clf.predict(X_test)))
joblib.dump(clf, model_path)
print(f"Model saved to {model_path}")
On a balanced dataset of ~100k URLs, this typically reaches 96-98% accuracy with a false positive rate under 2%. That is a reasonable starting point.
Using a language model as a second opinion
The classifier is fast and cheap — it should be your first filter. But for borderline cases (score between 0.3 and 0.7), you can escalate to a language model for contextual analysis.
An LLM can reason about things the feature extractor cannot: does the domain plausibly belong to the brand it is impersonating? Does the path structure match known phishing kit patterns? The key is structured output — a verdict and a reasoning string, not free-form text:
import json
def build_prompt(url: str) -> str:
return (
"You are a security analyst. Analyze this URL for phishing indicators.\n"
f"URL: {url}\n\n"
"Return JSON with these fields:\n"
' verdict: "phishing" | "legitimate" | "suspicious"\n'
" confidence: float 0.0-1.0\n"
" reasoning: one sentence\n"
" indicators: list of strings\n"
"Return only valid JSON, no other text."
)
def llm_assess_url(url: str, client) -> dict:
response = client.chat(build_prompt(url), max_tokens=256)
try:
return json.loads(response.text)
except json.JSONDecodeError:
return {"verdict": "suspicious", "confidence": 0.5, "reasoning": "parse error", "indicators": []}
def classify_url(url: str, clf, client, threshold_low=0.3, threshold_high=0.7) -> dict:
features = extract_features(url)
feature_df = pd.DataFrame([features])
score = float(clf.predict_proba(feature_df)[0][1])
if score >= threshold_high:
return {"verdict": "phishing", "score": score, "method": "classifier"}
elif score <= threshold_low:
return {"verdict": "legitimate", "score": score, "method": "classifier"}
else:
result = llm_assess_url(url, client)
result["score"] = score
result["method"] = "llm_escalation"
return result
This hybrid approach keeps costs low — the language model only sees the roughly 20% of URLs that land in the ambiguous middle band.
Operationalizing the detector
A few things matter when you move from notebook to production:
Latency: the feature extractor runs in microseconds. The classifier adds a few milliseconds. Reserve LLM calls for borderline cases and set a hard timeout (500ms is reasonable for a synchronous security check).
Domain age: WHOIS lookups add latency but are worth it for high-confidence verdicts. Domains registered in the last 30 days are disproportionately malicious. Cache results aggressively.
Feedback loop: every false positive costs user trust. Build a reporting endpoint from day one and use the collected corrections to retrain monthly.
What the model will not catch: homograph attacks, zero-day phishing kits not yet in training data, and legitimate services that just happen to look suspicious. A threat intelligence feed — checking URLs against known-bad lists — fills the gaps a classifier alone cannot cover. See our security hardening checklists for a structured reference of additional defensive layers worth stacking alongside URL detection.
The takeaway
Phishing URL detection is a well-scoped problem with measurable real-world impact. A feature-based gradient boosting classifier gets you to ~97% accuracy with minimal infrastructure. Adding a language model escalation layer handles the ambiguous cases that sit on the decision boundary. Together they make a solid, explainable, cheap-to-run first line of defense.
Start with the feature extractor. Train on PhishTank + Tranco. Measure your false positive rate against your own traffic before deploying in blocking mode. Iterate from there.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)