{"slug": "building-an-ai-powered-phishing-url-detector", "title": "Building an AI-powered phishing URL detector", "summary": "A developer detailed the construction of an AI-powered phishing URL detector using Python, combining feature engineering, a gradient boosting classifier, and a language model escalation layer for ambiguous cases. The system analyzes structural patterns such as domain entropy, keyword presence, and subdomain counts to flag malicious URLs before user clicks.", "body_md": "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`\n\npasses a casual glance. Your users won't catch it. A well-built detector can.\n\nThis 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.\n\nMost 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`\n\n, `netflix`\n\n, `google`\n\n) in odd positions — a subdomain or path component rather than the registrable domain itself.\n\nThese 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.\n\nBefore touching any model, decide what you're measuring. Here are the features that consistently show up in practice:\n\n`mail.secure.paypal.verification.com`\n\nhas more dots than `paypal.com`\n\n`http://192.168.1.1/login`\n\nis a classic tell`login`\n\n, `verify`\n\n, `account`\n\n, `secure`\n\n, `update`\n\n``` python\nimport re\nimport math\nfrom urllib.parse import urlparse\nfrom collections import Counter\n\nSUSPICIOUS_KEYWORDS = {\"login\", \"verify\", \"account\", \"secure\", \"update\", \"confirm\", \"bank\"}\nURL_SHORTENERS = {\"bit.ly\", \"tinyurl.com\", \"t.co\", \"goo.gl\", \"ow.ly\"}\n\ndef shannon_entropy(s: str) -> float:\n    if not s:\n        return 0.0\n    counts = Counter(s)\n    total = len(s)\n    return -sum((c / total) * math.log2(c / total) for c in counts.values())\n\ndef extract_features(url: str) -> dict:\n    parsed = urlparse(url if url.startswith(\"http\") else f\"https://{url}\")\n    hostname = parsed.hostname or \"\"\n    path = parsed.path or \"\"\n    full = url.lower()\n    digits = sum(c.isdigit() for c in hostname)\n    return {\n        \"url_length\": len(url),\n        \"hostname_length\": len(hostname),\n        \"dot_count\": hostname.count(\".\"),\n        \"digit_ratio\": digits / len(hostname) if hostname else 0,\n        \"has_ip\": bool(re.fullmatch(r\"\\d{1,3}(\\.\\d{1,3}){3}\", hostname)),\n        \"is_shortener\": hostname in URL_SHORTENERS,\n        \"keyword_count\": sum(kw in full for kw in SUSPICIOUS_KEYWORDS),\n        \"entropy\": shannon_entropy(hostname),\n        \"path_length\": len(path),\n        \"has_at_sign\": \"@\" in url,\n        \"subdomain_count\": max(0, len(hostname.split(\".\")) - 2),\n        \"uses_https\": parsed.scheme == \"https\",\n    }\n```\n\nRun this against a few URLs to sanity-check — `paypal.com`\n\nshould score very differently from `secure-paypal-login.verification-center.com`\n\n.\n\nA gradient boosting model (scikit-learn's `HistGradientBoostingClassifier`\n\n) 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.\n\nFor training data, the PhishTank dataset (phishing URLs) and the Tranco top-1M (legitimate URLs) are the standard sources. Here's the training scaffold:\n\n``` python\nimport pandas as pd\nfrom sklearn.ensemble import HistGradientBoostingClassifier\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import classification_report\nimport joblib\n\ndef load_dataset(phishing_path: str, legit_path: str) -> tuple[pd.DataFrame, pd.Series]:\n    phishing_urls = pd.read_csv(phishing_path, names=[\"url\"])[\"url\"].dropna().tolist()\n    legit_urls = pd.read_csv(legit_path, names=[\"url\"])[\"url\"].dropna().tolist()\n    records = []\n    for url in phishing_urls:\n        feats = extract_features(url)\n        feats[\"label\"] = 1\n        records.append(feats)\n    for url in legit_urls:\n        feats = extract_features(url)\n        feats[\"label\"] = 0\n        records.append(feats)\n    df = pd.DataFrame(records)\n    return df.drop(\"label\", axis=1), df[\"label\"]\n\ndef train(phishing_path: str, legit_path: str, model_path: str = \"phishing_model.joblib\"):\n    X, y = load_dataset(phishing_path, legit_path)\n    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)\n    clf = HistGradientBoostingClassifier(max_iter=200, learning_rate=0.05, max_depth=6)\n    clf.fit(X_train, y_train)\n    print(classification_report(y_test, clf.predict(X_test)))\n    joblib.dump(clf, model_path)\n    print(f\"Model saved to {model_path}\")\n```\n\nOn 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.\n\nThe 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.\n\nAn 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:\n\n``` php\nimport json\n\ndef build_prompt(url: str) -> str:\n    return (\n        \"You are a security analyst. Analyze this URL for phishing indicators.\\n\"\n        f\"URL: {url}\\n\\n\"\n        \"Return JSON with these fields:\\n\"\n        '  verdict: \"phishing\" | \"legitimate\" | \"suspicious\"\\n'\n        \"  confidence: float 0.0-1.0\\n\"\n        \"  reasoning: one sentence\\n\"\n        \"  indicators: list of strings\\n\"\n        \"Return only valid JSON, no other text.\"\n    )\n\ndef llm_assess_url(url: str, client) -> dict:\n    response = client.chat(build_prompt(url), max_tokens=256)\n    try:\n        return json.loads(response.text)\n    except json.JSONDecodeError:\n        return {\"verdict\": \"suspicious\", \"confidence\": 0.5, \"reasoning\": \"parse error\", \"indicators\": []}\n\ndef classify_url(url: str, clf, client, threshold_low=0.3, threshold_high=0.7) -> dict:\n    features = extract_features(url)\n    feature_df = pd.DataFrame([features])\n    score = float(clf.predict_proba(feature_df)[0][1])\n    if score >= threshold_high:\n        return {\"verdict\": \"phishing\", \"score\": score, \"method\": \"classifier\"}\n    elif score <= threshold_low:\n        return {\"verdict\": \"legitimate\", \"score\": score, \"method\": \"classifier\"}\n    else:\n        result = llm_assess_url(url, client)\n        result[\"score\"] = score\n        result[\"method\"] = \"llm_escalation\"\n        return result\n```\n\nThis hybrid approach keeps costs low — the language model only sees the roughly 20% of URLs that land in the ambiguous middle band.\n\nA few things matter when you move from notebook to production:\n\n**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).\n\n**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.\n\n**Feedback loop**: every false positive costs user trust. Build a reporting endpoint from day one and use the collected corrections to retrain monthly.\n\n**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](https://ayinedjimi-consultants.fr/checklists) for a structured reference of additional defensive layers worth stacking alongside URL detection.\n\nPhishing 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.\n\nStart 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.\n\n*I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.*", "url": "https://wpnews.pro/news/building-an-ai-powered-phishing-url-detector", "canonical_source": "https://dev.to/ayinedjimi-consultants/building-an-ai-powered-phishing-url-detector-2bph", "published_at": "2026-08-02 10:06:41+00:00", "updated_at": "2026-08-02 10:42:53.683937+00:00", "lang": "en", "topics": ["machine-learning", "artificial-intelligence", "large-language-models", "ai-products"], "entities": ["PhishTank", "Tranco", "scikit-learn", "Python"], "alternates": {"html": "https://wpnews.pro/news/building-an-ai-powered-phishing-url-detector", "markdown": "https://wpnews.pro/news/building-an-ai-powered-phishing-url-detector.md", "text": "https://wpnews.pro/news/building-an-ai-powered-phishing-url-detector.txt", "jsonld": "https://wpnews.pro/news/building-an-ai-powered-phishing-url-detector.jsonld"}}