skip to content

How can the next parameter in a Django login flow become an open redirect, and how does url_has_allowed_host_and_scheme() prevent it?

level: seniorimportance: should knowfreq 45%

answer

  1. user-controlled redirect target
  2. the built-in view already checks
  3. hand-rolled login views don't
  4. allowed hosts plus scheme

basics

~20 s

next is user input, so a view that redirects to it blindly can send victims to an attacker's site after a real login. django.utils.http.url_has_allowed_host_and_scheme() accepts only relative URLs or allowed hosts, with https enforced when required.

solid answer

~30 s

The guards put the original path in `?next=`, and the login view redirects there after sign-in. Because anyone can craft `/accounts/login/?next=https://evil.example/`, a custom view doing `redirect(request.GET["next"])` is an **open redirect**: the victim logs in on the real site and lands on a phishing page. Django's `LoginView` and `LogoutView` validate it with `url_has_allowed_host_and_scheme(url, allowed_hosts={request.get_host(), *success_url_allowed_hosts}, require_https=request.is_secure())` and fall back to `LOGIN_REDIRECT_URL` when it fails. The function returns `False` for empty values, foreign hosts, `//evil.example` and backslash variants, schemes other than http/https, `///` and leading control characters. In a hand-written view, call it the same way and redirect to a safe default otherwise.

code

python · 8 lines
python
from django.utils.http import url_has_allowed_host_and_scheme

allowed = {"tool.example"}
url_has_allowed_host_and_scheme("/reports/", allowed)                    # True
url_has_allowed_host_and_scheme("https://tool.example/r/", allowed)      # True
url_has_allowed_host_and_scheme("//evil.example/", allowed)              # False
url_has_allowed_host_and_scheme("/\\evil.example", allowed)              # False
url_has_allowed_host_and_scheme("http://tool.example/", allowed, require_https=True)  # False

go deeper

for a junior

Know that next is user-controlled and that Django's own login view validates it before redirecting.

for a middle

Explain the arguments of url_has_allowed_host_and_scheme and the fallback to LOGIN_REDIRECT_URL when validation fails.

for a senior

Show the bypass shapes a naive startswith('/') check misses (scheme-relative, backslash, triple slash) and audit every custom view that redirects to user input.

for a principal

Treat redirect targets as a shared control: one helper, one allow-list mechanism, and a review rule that no view redirects to raw request data.

## Where next comes from When `login_required`, `LoginRequiredMixin` or `LoginRequiredMiddleware` stops an anonymous request, it redirects to the login page with the original location in a query parameter, `next` by default: ```text /accounts/login/?next=/reports/2026/ ``` After a successful sign-in the login view redirects to that value. The problem is that the value arrives from the **browser**, so anyone can write a link with any `next` they like. ## The attack An **open redirect** is an endpoint that forwards users to an arbitrary, attacker-chosen URL. Here it looks like this: 1. The attacker sends a link to the real site: `https://tool.example/accounts/login/?next=https://evil.example/login-again`. 2. The victim sees the genuine domain and signs in normally. 3. A careless view redirects to `next`, landing the victim on a look-alike page that asks for the password "again". The real site's reputation carries the phishing link. A related variant uses `next=javascript:...` if the value is ever rendered into a link. ## What Django's views already do `LoginView` and `LogoutView` share a redirect helper whose `get_redirect_url()` reads the redirect field from POST or GET and passes it through: ```python url_has_allowed_host_and_scheme( url=redirect_to, allowed_hosts={request.get_host(), *self.success_url_allowed_hosts}, require_https=request.is_secure(), ) ``` If the check fails, the URL is discarded and the view falls back to its default (`LOGIN_REDIRECT_URL`, default `/accounts/profile/`, for login). `success_url_allowed_hosts` is the per-view hook for trusted extra domains, such as a sibling app on another subdomain. ## What the function rejects `django.utils.http.url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False)` returns `True` only when the URL has no host or an allowed host, and a scheme of `http`/`https` (just `https` when `require_https=True`). It also defends against browser parsing quirks: | Input | Result | Reason | |---|---|---| | `/reports/2026/` | allowed | Relative path, no host | | `https://tool.example/x` with that host allowed | allowed | Host in `allowed_hosts` | | `https://evil.example/` | rejected | Host not allowed | | `//evil.example/` | rejected | Scheme-relative URL with a foreign host, treated as http | | `/\evil.example` | rejected | Backslashes are re-checked as slashes, as some browsers read them | | `///evil.example` | rejected | Three leading slashes are refused outright | | `javascript:alert(1)` | rejected | A scheme with no host, and not http or https | | empty string or `None` | rejected | Nothing to redirect to | A `True` result means the host and scheme are acceptable, not that the URL is harmless in every context; the function's own docstring still advises `iri_to_uri()` on untrusted path components. ## Doing it right in your own view ```python from django.conf import settings from django.shortcuts import redirect, resolve_url from django.utils.http import url_has_allowed_host_and_scheme def finish_sso_login(request): # ... user has been authenticated and logged in by now ... target = request.POST.get("next") or request.GET.get("next") if not url_has_allowed_host_and_scheme( target, allowed_hosts={request.get_host()}, require_https=request.is_secure() ): target = resolve_url(settings.LOGIN_REDIRECT_URL) return redirect(target) ``` Points interviewers look for: - **Pass `request.get_host()`**, which is itself validated against `ALLOWED_HOSTS`, rather than a hard-coded list that drifts. - **Set `require_https=request.is_secure()`** so an https page never redirects to plain http. - **Always have a safe fallback**; never raise or render the raw value. - **Reuse Django's redirect helper** by subclassing `LoginView` when possible instead of re-implementing it. - The old `is_safe_url()` was renamed to `url_has_allowed_host_and_scheme()` in Django 3.0 and removed in 4.0; older tutorials still show it. ## Beyond login The same parameter pattern shows up in logout, language switching and "return to" links. Any view that redirects to a user-supplied URL needs the same check. ## How to spot it in a code review Search the codebase for the places user input reaches a redirect: 1. `redirect(request.GET.get(...))`, `redirect(request.POST[...])` and `HttpResponseRedirect(...)` built from request data. 2. Templates that render `{{ request.GET.next }}` into an `href` or a hidden form field without the view validating it first. 3. Custom login, logout, language-switch or SSO callback views that copied an old tutorial's `is_safe_url()` logic, or skipped the check entirely. Each hit should either go through `url_has_allowed_host_and_scheme()` with `request.get_host()` or be replaced by a subclass of Django's own view, which already performs the check and exposes `success_url_allowed_hosts` for legitimate cross-host targets.

  • Why pass request.get_host() rather than trusting the Host header directly?
    `request.get_host()` validates the Host header against `ALLOWED_HOSTS` and raises `DisallowedHost` for anything else, so a forged Host cannot widen the allow-list. Reading `request.META["HTTP_HOST"]` yourself would skip that check and let an attacker declare their own domain safe.
  • A company runs the login app on one subdomain and the dashboard on another; how do you let next point at the dashboard?
    Subclass `LoginView` and set `success_url_allowed_hosts = {"dashboard.example"}`, or override `get_success_url_allowed_hosts()`. The view adds these to `request.get_host()` when validating `next`, so only the named sibling host becomes acceptable.

saying these in an interview costs you the question

  • Checking that next starts with a slash is enough to block external redirects.
  • Django's LoginView redirects to next without any validation.
  • is_safe_url() is the current name of Django's redirect checker.
  • An open redirect is harmless because the user still logged in on the real site.
  • url_has_allowed_host_and_scheme returning True guarantees the URL is safe everywhere.