Fixing Python requests Session That Drops Auth Headers on Redirect

June 20, 2026 9 min read 2 views

You build an API client, attach an Authorization header to your requests.Session, and everything works until the server issues a redirect. The next response is a 401 Unauthorized. You add logging and discover the header is simply gone β€” stripped silently by requests before the redirected request even leaves your machine.

This is one of those bugs that looks like a server problem but is actually a deliberate behavior in the requests library. Understanding why it happens is the fastest path to fixing it correctly.

What you'll learn

  • Exactly when and why requests strips the Authorization header on a redirect.
  • Four concrete fixes, from the simplest to the most robust.
  • Which approach fits each scenario (same-origin redirects vs. cross-origin, Bearer vs. Basic auth).
  • Pitfalls that cause the fix itself to introduce a security leak.

Why Your Auth Header Disappears on Redirect

The requests library follows RFC 9110 guidance that says credentials should not be forwarded to a different origin. When a redirect crosses a host boundary β€” say, from api.example.com to auth.example.io β€” forwarding your token to the new host could expose it to a server you never intended to trust.

So requests makes a conservative choice: it strips sensitive headers. The Authorization header is always on that list. So is Proxy-Authorization. This behavior lives in the SessionRedirectMixin inside requests/sessions.py and is not a bug β€” but it frequently surprises developers whose API sits behind a load balancer or CDN that issues a same-origin redirect.

How requests Decides What to Strip

During a redirect, requests calls an internal method called rebuild_auth. That method checks whether the hostname in the redirect URL matches the hostname in the original request. If they differ, it strips the Authorization header and calls the session's auth callable (if any) to re-apply credentials.

If you set your token directly on session.headers rather than on session.auth, rebuild_auth cannot re-apply it β€” the header is just gone. That distinction matters a lot when you pick a fix.

# What many developers do β€” this is the fragile pattern
import requests

session = requests.Session()
session.headers.update({"Authorization": "Bearer YOUR_TOKEN"})

# Works fine on the first request, but if the server redirects
# to a different host, the Authorization header will be stripped.
response = session.get("https://api.example.com/data")
print(response.status_code)

Reproducing the Problem Locally

Before fixing anything, confirm you can reproduce the stripping behavior. The quickest way is to point your session at a URL that returns a 301 or 302 to a different host, then inspect what headers the redirected request actually carries.

import requests

session = requests.Session()
session.headers.update({"Authorization": "Bearer test-token"})

# Send the request with redirect following enabled (the default)
response = session.get(
    "https://httpbin.org/redirect-to",
    params={"url": "https://httpbingo.org/headers"},  # different host
    allow_redirects=True,
)

# httpbingo echoes back the headers it received
print(response.json()["headers"])
# You will NOT see Authorization here

Run this and you will see that httpbingo.org never receives the Authorization header. Now you have a reproducible case to validate each fix against.

Fix 1: Disable Automatic Redirects and Handle Them Yourself

The bluntest fix is to turn off automatic redirect following and handle the 3xx responses manually. This gives you full control over which headers travel to which host.

import requests
from urllib.parse import urlparse

def get_with_auth(session: requests.Session, url: str, token: str, max_redirects: int = 10) -> requests.Response:
    for _ in range(max_redirects):
        response = session.get(url, allow_redirects=False)
        if response.status_code not in (301, 302, 303, 307, 308):
            return response

        redirect_url = response.headers.get("Location", "")
        original_host = urlparse(url).netloc
        redirect_host = urlparse(redirect_url).netloc

        # Only forward the token to the same host
        if original_host == redirect_host:
            session.headers.update({"Authorization": f"Bearer {token}"})
        else:
            session.headers.pop("Authorization", None)

        url = redirect_url

    raise requests.TooManyRedirects(f"Exceeded {max_redirects} redirects")


session = requests.Session()
response = get_with_auth(session, "https://api.example.com/data", token="YOUR_TOKEN")
print(response.status_code)

This is verbose but transparent. You can see exactly what happens at every hop. The downside is that you are reimplementing redirect logic that requests already handles (method changes on 303, relative URL resolution, etc.), so bugs can creep in.

Fix 2: Override rebuild_auth in a Custom Session

A cleaner approach is to subclass requests.Session and override rebuild_auth to skip the header-stripping step. Use this only when you know every redirect stays within a trusted domain β€” for example, an internal API that redirects between subdomains you own.

import requests
from requests import PreparedRequest


class StickyAuthSession(requests.Session):
    """A session that preserves the Authorization header across same-domain redirects."""

    TRUSTED_DOMAINS = {"example.com"}  # Only trust redirects within these domains

    def rebuild_auth(self, prepared_request: PreparedRequest, response):
        """Override to retain auth headers for trusted domains."""
        from urllib.parse import urlparse

        original_host = urlparse(response.request.url).hostname or ""
        redirect_host = urlparse(prepared_request.url).hostname or ""

        # Check if both hosts belong to a trusted domain
        def is_trusted(host: str) -> bool:
            return any(host == d or host.endswith(f".{d}") for d in self.TRUSTED_DOMAINS)

        if is_trusted(original_host) and is_trusted(redirect_host):
            # Don't strip β€” just re-apply the session auth if any
            if self.auth:
                prepared_request = self.auth(prepared_request)
            return  # Keep existing headers intact

        # Fall back to the default behavior for untrusted cross-origin redirects
        super().rebuild_auth(prepared_request, response)


session = StickyAuthSession()
session.headers.update({"Authorization": "Bearer YOUR_TOKEN"})
response = session.get("https://api.example.com/data")
print(response.status_code)

The TRUSTED_DOMAINS set is the critical safety net here. Without it, any redirect β€” including an attacker-controlled one β€” would receive your token. Always scope the bypass as narrowly as possible.

Fix 3: Use a Custom AuthBase Class

The most idiomatic requests fix is to attach credentials via session.auth rather than session.headers. When rebuild_auth strips the header, it immediately calls your auth callable to re-add it. If your auth class adds the header unconditionally, the header survives every redirect.

import requests
from requests.auth import AuthBase


class BearerTokenAuth(AuthBase):
    """Attaches a Bearer token to every request, including after redirects."""

    def __init__(self, token: str):
        self.token = token

    def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
        request.headers["Authorization"] = f"Bearer {self.token}"
        return request


session = requests.Session()
session.auth = BearerTokenAuth("YOUR_TOKEN")

# The token is now re-applied on every hop, including cross-origin redirects.
response = session.get("https://api.example.com/data")
print(response.status_code)

This works because rebuild_auth calls self.auth(prepared_request) after stripping sensitive headers. Your __call__ method puts the token back unconditionally. It is clean, testable, and composes well with other session configuration.

The tradeoff is the same one from Fix 2: you are now sending your token to any host the server redirects you to. If the API you are calling redirects to a third-party URL, your token goes there too. For public APIs this is rarely acceptable. Pair this with a max_redirects limit or domain validation inside __call__.

from urllib.parse import urlparse


class SafeBearerTokenAuth(AuthBase):
    """Applies a Bearer token only to requests within a trusted host."""

    def __init__(self, token: str, trusted_host: str):
        self.token = token
        self.trusted_host = trusted_host

    def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
        host = urlparse(request.url).hostname or ""
        if host == self.trusted_host or host.endswith(f".{self.trusted_host}"):
            request.headers["Authorization"] = f"Bearer {self.token}"
        return request


session = requests.Session()
session.auth = SafeBearerTokenAuth("YOUR_TOKEN", trusted_host="example.com")
response = session.get("https://api.example.com/data")
print(response.status_code)

If you've run into the related issue where a request appears to succeed but returns no data, the article on fixing Python requests that returns 200 but has an empty response body covers that separate failure mode in detail.

Fix 4: Pin the Authorization Header with an Event Hook

requests supports response hooks that fire after each response, including intermediate redirects. You can use the 'response' hook to inspect the request that's about to be retried and re-inject the header if it went missing.

import requests


def enforce_auth_header(token: str):
    """Returns a hook function that re-adds the Authorization header after redirects."""

    def hook(response, **kwargs):
        if response.is_redirect:
            # The next request is being prepared; patch its headers before it fires
            prepared = response.request
            prepared.headers["Authorization"] = f"Bearer {token}"

    return hook


session = requests.Session()
token = "YOUR_TOKEN"
session.headers.update({"Authorization": f"Bearer {token}"})
session.hooks["response"].append(enforce_auth_header(token))

response = session.get("https://api.example.com/data")
print(response.status_code)

This approach looks appealing but has a subtle timing problem: the hook fires after the response arrives but the prepared request for the redirect is already being built internally. In practice, patching response.request in the hook does not reliably affect the next outgoing request. Treat this as a last resort and test it thoroughly against your specific version of requests. Fix 3 (the custom AuthBase) is more reliable for the same goal.

While you're hardening your HTTP client, you should also make sure you're handling timeouts correctly. A session with no timeout can stall indefinitely β€” the article on fixing Python requests that hang without raising a timeout error explains how to set timeouts that actually work.

Which Fix Should You Use?

Scenario Recommended Fix
Redirect stays on same host or subdomain you own Fix 2 (override rebuild_auth with domain check)
Any redirect, internal API, you control both ends Fix 3 (SafeBearerTokenAuth with trusted host)
You need hop-by-hop control or audit logging Fix 1 (manual redirect loop)
Third-party API, unknown redirect destination Fix 1 or Fix 3 with strict domain validation

For most internal microservice clients, Fix 3 with a SafeBearerTokenAuth is the right default. It is idiomatic, composable, and keeps the security logic in one place.

Common Pitfalls

Storing tokens in session.headers and wondering why auth breaks

Headers set via session.headers survive same-origin redirects but are stripped on cross-origin ones. If you own the entire infrastructure, that may be fine. If you are calling any external API, use session.auth instead so the re-application logic is explicit.

Forgetting that 307 and 308 preserve the HTTP method

A 301 or 302 redirect of a POST request is conventionally reissued as a GET. A 307 or 308 keeps the original method and body. If you are implementing Fix 1 (manual redirect loop), account for this or you will silently turn POST requests into GET requests after a redirect.

Trusting Location headers blindly

The Location header in a redirect response is server-controlled. If you blindly forward credentials to whatever URL it contains, a compromised server can redirect you to an attacker's endpoint. Always validate the redirect destination against a known-good domain list before forwarding auth headers.

Not testing with an actual redirect

Mocking your HTTP calls with responses or unittest.mock will not reproduce the redirect-stripping behavior unless the mock explicitly issues a 3xx and lets requests follow it. Use a real test server or httpretty with redirect support to validate your fix end-to-end.

Wrapping Up

The auth header stripping on redirect is a deliberate security feature, not a bug you can simply disable. The goal is to stop credentials leaking to untrusted hosts. Every fix in this article preserves that intent while giving you control over the specific case where requests is too conservative for your setup.

Concrete next steps:

  1. Identify where your token is set. If it is in session.headers, move it to a custom AuthBase subclass immediately.
  2. Add a trusted-host check to your auth class or rebuild_auth override. Scope it to the exact hostname or domain suffix of your API.
  3. Write a redirect integration test that hits an actual 3xx response β€” not a mock β€” so you catch regressions before production.
  4. Set a max_redirects limit on your session (session.max_redirects = 5) to avoid infinite loops if the server misbehaves.
  5. Audit other sensitive headers (Cookie, custom API-Key headers) for the same stripping behavior if your API uses non-standard authentication schemes.

Frequently Asked Questions

Why does Python requests remove the Authorization header when following a redirect?

Python requests strips the Authorization header on cross-origin redirects by design, following HTTP security guidance that says credentials should not be forwarded to a different host. This prevents your token from leaking to servers you did not explicitly trust. The behavior is controlled by the internal rebuild_auth method in SessionRedirectMixin.

How do I keep the Bearer token attached after a redirect in a requests session?

The most reliable way is to create a custom AuthBase subclass that sets the Authorization header in its __call__ method and assign it to session.auth. Because requests calls the auth callable after stripping headers during a redirect, your token gets re-applied automatically. Add a host check inside __call__ to avoid forwarding the token to unintended domains.

Does setting headers on session.headers behave differently from session.auth during redirects?

Yes, they behave very differently. Headers in session.headers are stripped when requests detects a cross-origin redirect and there is no auth callable to re-apply them. Headers applied by a session.auth callable are re-applied after stripping because rebuild_auth calls the auth function again. For any auth header that must survive redirects, use session.auth.

Is it safe to override rebuild_auth to stop requests from stripping the Authorization header?

It can be safe if you add a strict domain check that limits the bypass to hosts you own and trust. Without that guard, any redirect β€” including one issued by a compromised or third-party server β€” would receive your credentials. Scope your trusted-domain list as narrowly as possible.

How can I tell which hop in a redirect chain is dropping my Authorization header?

Set allow_redirects=False and follow each redirect manually, printing the headers of the prepared request before each hop. Alternatively, enable HTTP-level debug logging with http.client.HTTPConnection.debuglevel = 1 and logging.basicConfig(level=logging.DEBUG), which prints raw request headers for every connection requests opens.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.