Safeguard
DevSecOps

How to Validate a URL in Python Safely

A practical guide to validating URLs in Python without opening SSRF or injection holes — what the standard library gives you, where it fails, and how to build a safe validator.

Priya Mehta
DevSecOps Engineer
6 min read

The safe way to validate a URL in Python is to parse it with urllib.parse.urlparse, then enforce an explicit allowlist of schemes and hosts — not to match it against a regular expression. Most of the "validate url python" snippets you find online use a giant regex copied from a Stack Overflow answer, and nearly all of them either reject valid URLs, accept malformed ones, or — worse — pass a string that looks fine but points at 169.254.169.254 and hands an attacker your cloud metadata credentials. Validation is only half the job; the other half is deciding what a valid URL is allowed to do.

What "valid" actually means

Before you write any code, pin down what you are validating for. "Is this a syntactically well-formed URL?" is a completely different question from "Is this URL safe to fetch server-side?" The first is a parsing problem. The second is a security-boundary problem, and it is where most bugs live.

The Python standard library handles parsing cleanly:

from urllib.parse import urlparse

def is_well_formed(url: str) -> bool:
    try:
        result = urlparse(url)
    except ValueError:
        return False
    return all([result.scheme, result.netloc])

This checks that the string parses and has both a scheme (https) and a network location (example.com). It does not check that the scheme is one you want, that the host is reachable, or that the URL is safe to request. Treat is_well_formed as step one, never as the whole answer.

Why regex-based URL validation goes wrong

The temptation to validate url python-style with a single regex is strong because it feels self-contained. It isn't. URLs are defined by RFC 3986, and a regex that fully implements that grammar is thousands of characters long and still misses edge cases like internationalized domain names and percent-encoding. The short regexes people actually paste tend to reject valid inputs (http://localhost:8000, URLs with + in the path) and accept obviously bad ones (javascript:alert(1), http://evil.com@internal-host/).

That last example is the classic trap. http://good.com@169.254.169.254/ has a userinfo component of good.com and a host of 169.254.169.254. A naive regex or a startswith("http://good.com") check passes it; urlparse correctly reports the host as the metadata endpoint. Parsing beats pattern-matching precisely because it understands structure.

Building a validator that enforces a policy

A real validator combines a parse with a policy. Here is a version that rejects non-HTTP schemes and requires a hostname:

from urllib.parse import urlparse

ALLOWED_SCHEMES = {"https"}

def validate_url(url: str) -> str:
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError(f"scheme not allowed: {parsed.scheme!r}")
    if not parsed.hostname:
        raise ValueError("missing host")
    return url

Allowlisting the scheme kills an entire bug class in one line. javascript:, file:, ftp:, data:, and gopher: all get rejected without you having to enumerate them. If you only ever fetch HTTPS, allow only HTTPS. Add http only if you have a concrete reason to.

For libraries, the community package validators gives you a battle-tested validators.url() check that handles more of the RFC than you want to reimplement. It is a good syntactic gate, but it still does not make a decision about where the URL points — you own that.

Stopping SSRF: validating the destination, not just the string

If your Python code fetches a URL supplied by a user — webhooks, "import from URL," link previews, avatar fetchers — you are exposed to server-side request forgery (SSRF). The fix is to resolve the host and check the resulting IP against a blocklist of private and link-local ranges before you make the request.

import ipaddress
import socket
from urllib.parse import urlparse

def is_public_host(url: str) -> bool:
    host = urlparse(url).hostname
    if host is None:
        return False
    try:
        infos = socket.getaddrinfo(host, None)
    except socket.gaierror:
        return False
    for info in infos:
        ip = ipaddress.ip_address(info[4][0])
        if (ip.is_private or ip.is_loopback or ip.is_link_local
                or ip.is_reserved or ip.is_multicast):
            return False
    return True

Two things matter here. First, you must check every address getaddrinfo returns, because a hostname can resolve to multiple IPs. Second, this check has a time-of-check-to-time-of-use gap: DNS can return a public IP now and a private one when your HTTP client re-resolves it milliseconds later (a "DNS rebinding" attack). The durable fix is to resolve once, validate the IP, then connect to that exact IP while passing the original hostname for TLS/SNI. Libraries like a custom requests transport adapter or the advocate package implement this pattern.

The same problem in other languages

This is not a Python-only concern. Every language that fetches user-supplied URLs faces it. If you also work in .NET, the c# validate url idiom is Uri.TryCreate(input, UriKind.Absolute, out var uri) followed by a check that uri.Scheme == Uri.UriSchemeHttps — structurally identical advice: parse with the platform primitive, then enforce a scheme allowlist, then validate the resolved destination. The language changes; the threat model does not.

Putting it together

A production-grade URL validator in Python runs three gates in order: parse and confirm the string is well-formed, enforce a scheme allowlist, and resolve the host to confirm it is a public address you are willing to reach. Skip any one of them and you have left a gap. The most common real-world failure is teams that nail the first gate, feel done, and ship an SSRF into their link-preview feature.

If you are pulling in third-party HTTP or URL-parsing libraries to do this, keep an eye on their advisories too — parsing libraries have had their own CVEs, and an SCA tool such as Safeguard can flag when a dependency you rely on for URL handling ships a known bug. For the broader input-validation and SSRF patterns, the Safeguard Academy has hands-on material.

FAQ

Is a regular expression ever the right way to validate a URL in Python?

For strict format checking against a known, narrow pattern (say, "must be an HTTPS URL on our own domain"), a small anchored regex is fine as a supplement. As the primary validator it is a liability — use urlparse or the validators package and layer scheme and host policy on top.

Does urlparse protect me from SSRF?

No. urlparse only tells you the structure of the URL. It correctly identifies the real host even in tricky cases, which helps, but you still have to resolve that host and check the IP against private ranges to stop SSRF.

What's the difference between validating a URL and sanitizing it?

Validation decides whether to accept or reject the input as-is. Sanitizing rewrites it to a safe form. For URLs you almost always want validation (reject bad input) rather than sanitizing, because silently altering a URL can change where a request goes in ways the user did not intend.

How do I validate a URL the same way in C#?

Use Uri.TryCreate with UriKind.Absolute, then check uri.Scheme against an allowlist and resolve the host to screen out private IPs — the same three-gate approach as Python.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.