Making an API call with requests using a Bearer token can unexpectedly send HTTP Basic Auth if a .netrc entry matches the hostname. For example, consider this code in a workshop lab where an owned loopback server simply classifies incoming authentication:
from requests import Request, Session
req = Request(
'GET',
'http://127.0.0.1/data',
headers={'Authorization': 'Bearer lab-token-not-a-secret'}
)
s = Session()
prepped = s.prepare_request(req)
print("Prepared header:", prepped.headers.get('Authorization'))
resp = s.send(prepped, allow_redirects=False)
print("Server classification:", resp.text)If the user has a netrc entry like machine 127.0.0.1 login lab-user password lab-pass, the server might return netrc-basic-dummy, indicating that our Bearer header was replaced by Basic Auth. This is Requests’ documented behavior: when no auth is given, it looks up credentials in ~/.netrc and overrides any raw Authorization header. In other words, we intended Bearer but got Basic. This is not a compromised server or “magic”; it’s a precedence mismatch on the client.
Our scope is one outbound request’s credential source. We define the intended identity (“Bearer lab-token-not-a-secret” from our code) and the actual identity the server saw (Basic login “lab-user:lab-pass”). We then decide: ACCEPT if they match (“clean contract”), REPAIR if we caused an unintended override, HOLD if an identity is unexplained, or CONTAIN for real exposures beyond the intended boundary. All tests use an isolated Python subprocess with a temporary HOME and a dummy NETRC; we never touch real tokens or external services. Every environment change (HOME, NETRC, proxy vars) is confined to this test process and reset afterward.
This article equips you to declare the credential source before sending, inspect Requests’ preparation workflow, and enforce the intended scheme. We use an owned loopback endpoint that classifies the scheme without revealing credentials, and we step through five controlled arms. Each arm is independent (no shared session state) and ends with an explicit verdict: Accept the configuration, Repair the code, Hold for human review, or Contain if actual exposure is suspected. We then reconcile results in a verdict matrix. Finally, we assign each trust boundary an owner (caller maintainer, network/TLS admin, security reviewer) and link to broader secure-workflow education.
Declare the credential source before sending the request
We must clearly separate intended credentials from observed ones. For each client request, record:
Intended identity (scheme and token): for example, “Bearer lab-token-not-a-secret” from our code.
Configured auth providers: raw header, auth argument, Session.auth, netrc, etc.
Actual outgoing header: what the server received (checked via our loopback).
Trust settings: e.g. Session.trust_env value.
With this data, we make a call-specific decision. Possible outcomes:
ACCEPT: The observed header matches the intended identity provider (e.g. we intended Bearer and got Bearer). Credential source contract holds.
REPAIR: The server saw a different identity provider than intended (e.g. intended Bearer, got Basic). We must fix the caller or config.
HOLD: The request used an unspecified or unexpected provider (e.g. no provided auth but Basic appeared); require human review of environment.
CONTAIN/REVIEW: If real credentials were exposed beyond expectation, stop any further sends and follow incident response (though our lab is limited to local). Containment requires evidence (e.g. contact logs) of actual exfiltration.
In this controlled lab all responses are “200 OK” with a label, so we rely on the classification string in the body (e.g. "bearer-dummy", "netrc-basic-dummy") to know which credential scheme the server saw. A 200 alone doesn’t prove correctness; only our loopback’s label does. (In a real system, a Basic-authenticated endpoint might reject a Bearer token, but here our dummy endpoint simply reports the scheme.)
We link this to integration best practices for context: always document which credential source an API client intends to use, and then verify it actually took effect before trusting responses. Our approach extracts the prepared Authorization header and what was sent over the wire, so we trust actual evidence, not just the caller’s intent. The final verdict matrix will tie each request to an operational owner and action.
Isolate HOME, NETRC and the loopback endpoint
We set up a safe test sandbox in a child process (to avoid using any real credentials). This includes:
A temporary HOME directory (tempfile.TemporaryDirectory()), with no real NETRC in it. We explicitly create or set the NETRC path.
A dummy .netrc file in that HOME, with mode 0600, containing exactly machine 127.0.0.1 login lab-user password lab-pass. This matches our hostname (the server’s IP, not a DNS name) but does not include any port.
We then start an HTTP server bound to 127.0.0.1 on an ephemeral port. (The netrc entry uses 127.0.0.1; Requests matching uses hostname only, not port.) The server will not use TLS (it’s local HTTP only), but in a real case you should use HTTPS for real secrets.
We ensure proxies are disabled by unsetting HTTP_PROXY, HTTPS_PROXY, and adding NO_PROXY=127.0.0.1. This prevents the test from accidentally routing through a corporate proxy.
We print or log environment and versions: Python version, requests version, and a whitelist of environment variables (just to show what’s passed to Session vs. what is ignored). This confirms no real .netrc or token is read. (In our lab, requests was v2.32.5 on CPython 3.13.5, but we rely on API behavior, not version assumptions.)
Prevent the test from reading a real credential file
Even with a temp HOME, some code might still look elsewhere. To be safe, we set os.environ['HOME'] to our temp, and in code also specify NETRC path or let requests pick ~/.netrc in that home. In Python’s netrc module, os.path.expanduser('~') uses HOME. We also ensure no other netrc file is present by not creating netrc or similar. Before preparing any request, we could assert no requests auth is inadvertently coming from elsewhere. In our code, for example, we check requests.utils.getnetrc_auth returns our dummy creds only when expected, and None when it shouldn’t.
With this isolation, any Basic credentials our code sees must come from our dummy netrc file and not a real user’s file.
Build an endpoint that classifies but never echoes credentials
We implement a minimal HTTP server in Python that looks only at the incoming Authorization header and returns a label (not the actual header). For example:
from http.server import BaseHTTPRequestHandler, HTTPServer
import base64
class AuthClassifier(BaseHTTPRequestHandler):
def do_GET(self):
auth = self.headers.get('Authorization', '')
if auth.startswith('Bearer '):
label = 'bearer-dummy'
elif auth.startswith('Basic '):
creds = base64.b64decode(auth.split(' ',1)[1]).decode()
# Distinguish netrc vs session: creds = "user:pass"
if creds == 'lab-user:lab-pass':
label = 'netrc-basic-dummy'
elif creds == 'session-user:session-pass':
label = 'session-basic-dummy'
else:
label = 'basic-other'
else:
label = 'no-auth'
self.send_response(200)
self.end_headers()
self.wfile.write(label.encode())This server never echoes actual secrets. It only returns one of the known labels: "bearer-dummy", "netrc-basic-dummy", "session-basic-dummy", etc., and returns HTTP 200 in all cases. That way, a successful status always comes back, and we must trust the body’s label for truth.
Note: The loopback uses HTTP, but for production credentials always use HTTPS. We do not test TLS here; our focus is header precedence. Also, we do not log or print the Authorization header anywhere, so there is no risk of leaking it in our test logs.
(As a sanity check, an incoming request with no auth would produce label "no-auth". In our experiments, all arms send some auth header, so we expect one of the dummy labels.)
Reproduce netrc replacing the raw Bearer header
Case 1 (No netrc match, raw Bearer): We first confirm that if there is no matching entry in .netrc, a raw Bearer header is preserved. We simulate this by creating a .netrc that has a different machine (e.g. machine other-host ...) so it does not match 127.0.0.1. Then:
from requests import Request, Session
# No matching netrc entry for 127.0.0.1
req = Request(
'GET', url,
headers={'Authorization': 'Bearer lab-token-not-a-secret'}
)
s = Session()
# trust_env default True, but netrc has no entry for this host
prepped = s.prepare_request(req)
print("Prepared header (case1):", prepped.headers['Authorization'])
resp = s.send(prepped, timeout=5, allow_redirects=False)
print("Server saw:", resp.text)Expected behavior: The prepared header should still be 'Bearer lab-token-not-a-secret' and the server should return "bearer-dummy". Our loopback confirms the dummy Bearer passed through. This accepts the intention (Bearer→Bearer).
Case 2 (Matching netrc, raw Bearer, auth=None): Now we put the matching entry in .netrc. We call:
req = Request(
'GET', url,
headers={'Authorization': 'Bearer lab-token-not-a-secret'}
)
s = Session() # trust_env is True by default
prepped = s.prepare_request(req)
print("Prepared header (case2):", prepped.headers['Authorization'])
resp = s.send(prepped, timeout=5, allow_redirects=False)
print("Server saw:", resp.text)According to the netrc authentication documentation, if no auth argument is given, Requests will look up .netrc and override the raw Authorization. We expect:
prepped.headers['Authorization'] may still show 'Bearer lab-token-not-a-secret' before sending, because prepare_request does not reapply get_netrc_auth yet (it only attaches session and method params).
However, upon s.send(), Session.send with trust_env=True will call self.rebuild_auth which checks netrc, so the final sent header should be Basic. Indeed, our endpoint returns "netrc-basic-dummy".
We record: intended=Bearer, actual=Basic (netrc). Decision: REPAIR (Bearer was overwritten). For evidence, we compare the prepared headers (intact Bearer) vs. actual server result (Basic). This mismatch triggers repair.
A matching hostname is not the same as the request port
Our netrc uses machine 127.0.0.1. Note that netrc matching is by hostname only, not including port. Requests will match this even if the server port is ephemeral (e.g. 127.0.0.1:54321). In netrc format, we do not include the port. This is why our entry machine 127.0.0.1 suffices for any port on that host.
Test the empty-tuple workaround on the pinned runtime
Some developers try auth=() (an empty tuple) to disable auth. In code:
req = Request(
'GET', url,
headers={'Authorization': 'Bearer lab-token-not-a-secret'}
)
s = Session()
s.trust_env = True
prepped = s.prepare_request(req)
# Provide auth=() at send time:
resp = s.send(prepped, timeout=5, allow_redirects=False, auth=())
print("Server saw (case3):", resp.text)In our test on Requests 2.32.5, auth=() is truthy as an object but has no user/pass inside. The implementation treats it as a signal to use HTTPBasicAuth(None): effectively like no credentials. Because bool(()) is False, Requests does not see a valid auth tuple. The netrc lookup still happens. We observed that this still yields "netrc-basic-dummy". So empty tuple did NOT disable netrc. This is an observed negative-control test (not an official documented feature). It appears the code checks if auth is not None, finds it is not None (since () != None), but then probably passes it to rebuild_auth which treats it as a BasicAuth with empty credentials and falls back to netrc anyway. In summary, do not rely on auth=() to skip netrc. It’s not a supported usage.
(We document this against the source code comments, rather than calling it a feature. It means we should not teach auth=() as a fix. Instead, use one of the supported methods below.)
Make an explicit provider own the intended header
The robust way is to supply a proper AuthBase or tuple for Bearer. We define:
import requests
class BearerAuth(requests.auth.AuthBase):
def call(self, r):
r.headers['Authorization'] = 'Bearer lab-token-not-a-secret'
return r
# Case 4: with matching netrc in place, use explicit Bearer AuthBase
req = Request('GET', url)
s = Session()
s.trust_env = True
prepped = s.prepare_request(req)
resp = s.send(prepped, timeout=5, allow_redirects=False, auth=BearerAuth())
print("Server saw (case4):", resp.text)Here, by giving an explicit Auth handler, Requests will not do netrc lookup for that request, because we did provide an auth. We expect the endpoint returns "bearer-dummy". This means the explicit provider “owns” the header construction. We call this intentional Bearer and actual Bearer, so we accept this scenario. In the verdict matrix below, we’ll label the provider as “explicit AuthBase”.
Keep session auth and per-request auth distinguishable
We should also verify behavior when Session.auth is set. To avoid confusion, we use different dummy credentials. For example:
# Case 5a: Session has its own Basic auth
session = Session()
session.auth = ('session-user', 'session-pass')
prepped = session.prepare_request(Request('GET', url))
resp = session.send(prepped, timeout=5, allow_redirects=False)
print("Server saw (case5a):", resp.text) # expected 'session-basic-dummy'
# Case 5b: Session basic but request overrides with BearerAuth()
session = Session()
session.auth = ('session-user', 'session-pass')
req = Request('GET', url)
prepped = session.prepare_request(req)
resp = session.send(prepped, timeout=5, allow_redirects=False, auth=BearerAuth())
print("Server saw (case5b):", resp.text) # expected 'bearer-dummy'We expect Case 5a returns "session-basic-dummy", and 5b returns "bearer-dummy". That demonstrates that a per-request auth overrides the session-level auth when both are present, consistent with documented behavior. (The session-level Basic should only apply if no per-request auth is given.) In case 5b, we would accept the result because per-request Bearer was intended and observed.
Inspect session preparation without bypassing the session
To see Requests’ full process, we distinguish Request.prepare() from Session.prepare_request(). The former does not apply session-wide defaults like cookies, headers or auth. We use the recommended preparation pattern:
from requests import Request, Session
s = Session()
req = Request('GET', url, headers={'Authorization': 'Bearer lab-token-not-a-secret'})
prepped = s.prepare_request(req) # applies session settingsThis gives a PreparedRequest. Next, to send it we use merge_environment_settings and send():
settings = s.merge_environment_settings(
prepped.url, proxies={}, stream=False, verify=True, cert=None
)
resp = s.send(prepped, timeout=(3,3), allow_redirects=False, **settings)This pattern merges in environment proxies or SSL settings. It mirrors what Session.request() does internally. In our tests, we ensure allow_redirects=False (so we don’t lose headers on a redirect) and set timeouts. The settings dict includes proxies/verify from merge_environment_settings, which we will exploit next.
Alternatively, one can call s.request(...) and then inspect response.request to see the final headers. But the above makes explicit the preparation vs send stage and where trust_env affects the handshake. In our code we avoid any redirects and just read resp.request.headers and resp.text as cross-checks (though we mainly use the server label).
Review what trust_env=False changes besides netrc
Setting session.trust_env = False indeed stops .netrc lookup, but it also disables all environment-based config, including proxies and certificate bundles from the environment. We must not blindly flip it without checking. In our example, with matching netrc we saw that trust_env=False on the session preserved the raw header:
s = Session()
s.trust_env = False
req = Request('GET', url, headers={'Authorization': 'Bearer lab-token-not-a-secret'})
prepped = s.prepare_request(req)
resp = s.send(prepped, timeout=5, allow_redirects=False)
print("Server saw (case6):", resp.text)Here, despite a netrc entry, the session ignored it and the server returns "bearer-dummy". So for this case we would ACCEPT (we got the intended Bearer). However, to fully understand trust_env, we test environment merging separately.
Inspect environment merging without making a network call
We simulate environment overrides. For a fictional URL, we set os.environ['HTTPS_PROXY'] = 'http://proxy.example.com:8080' and os.environ['REQUESTS_CA_BUNDLE'] = '/tmp/fake-ca.pem'. Then:
from requests import Session
s = Session()
s.trust_env = True
settings_env_true = s.merge_environment_settings(
'https://example.invalid', proxies=None, stream=None, verify=None, cert=None
)
print("trust_env=True -> proxies:", settings_env_true['proxies'])
print("trust_env=True -> verify:", settings_env_true['verify'])
s.trust_env = False
settings_env_false = s.merge_environment_settings(
'https://example.invalid', proxies=None, stream=None, verify=None, cert=None
)
print("trust_env=False -> proxies:", settings_env_false['proxies'])
print("trust_env=False -> verify:", settings_env_false['verify'])Expected: With trust_env=True, settings['proxies'] should include our HTTPS_PROXY, and settings['verify'] should be the path from REQUESTS_CA_BUNDLE. With trust_env=False, both should revert to defaults (empty proxies {} and verify=True), showing no env influence. We do not connect to example.invalid. This proves that trust_env=False is global: it doesn’t just disable netrc, but all environment-based settings (proxies, CA bundles, etc.). A fix that requires proxies might need explicit Session.proxies even if we disable trust_env.
We emphasize that for a true production fix, one should inventory existing env dependencies and provide explicit alternatives (or reassess the design) before flipping trust_env=False.
Capture the actual one-hop request without leaking credentials
For each of the above arms, we collect: case ID, host, netrc match (yes/no), trust_env, session.auth, request.auth, prepared scheme (from code intent), actual endpoint classification. For example:
Case | netrc match | trust_env | Session.auth | request.auth | Intended | Actual (server) | Decision |
1 | No | True | None | None | Bearer | bearer-dummy | ACCEPT |
2 | Yes | True | None | None | Bearer | netrc-basic-dummy | REPAIR |
3 | Yes | True | None | auth=() | Bearer | netrc-basic-dummy | HOLD |
4 | Yes | True | None | BearerAuth() | Bearer | bearer-dummy | ACCEPT |
5a | Yes | True | ('session-user', 'session-pass') | None | Basic(session) | session-basic-dummy | ACCEPT |
5b | Yes | True | ('session-user', 'session-pass') | BearerAuth() | Bearer | bearer-dummy | ACCEPT |
6 | Yes | False | None | None | Bearer | bearer-dummy | ACCEPT |
(We label Case 3 “HOLD” because using auth=() is unsupported; it wasn’t intended, so the request’s context is confusing. In practice, one would fix code rather than treat it as valid. If that happened in real code, we’d hold and ask the developer to clarify. It is not acceptance of behavior.)
Notice for each row, “Intended” identity (from our code spec) and “Actual” from server. If they match, we could “accept” or keep; if they differ without a good explanation, we flag accordingly. We do not display actual headers or token values in this table to avoid leaking sensitive content. The table uses abstract labels.
We also note response.request (the PreparedRequest sent) matches what we prepared, confirming no redirect or rewrites happened en route. For instance, after resp = s.send(prepped, ...), resp.request.headers['Authorization'] contains exactly what the server saw. We make sure not to log the raw token, only confirm that it matches our expectation (or not).
Reconcile the entire authentication-precedence matrix
The above table is one summary of our observations. The key points are:
A matching .netrc entry will override an explicit raw Authorization header unless an auth provider is used. That matches Requests documentation.
A session-level auth is secondary to a per-request auth. We saw that a Session.auth=('user','pass') made a Basic header only when no per-request auth was given (case 5a).
trust_env=False prevented .netrc from being used in case 6, but also turned off any environment proxies or CA settings.
Using an explicit AuthBase (case 4, 5b) ensured our Bearer header stayed intact even with a matching netrc. That is the recommended repair when you intend Bearer.
Do not accept a scheme without its declared identity
A common pitfall is to mark “Basic” as acceptable just because it was expected elsewhere. In case 5a, we expected Basic with session creds, so we labeled it ACCEPT (session-basic). But in case 2, Basic was not intended (we meant to send Bearer), so we mark that REPAIR. The scheme plus credentials identity must match the caller’s contract. We do not accept Basic in case 2 just because “we got a 200”. The wrong identity means the client’s intent was violated.
Repair the owned caller instead of deleting ambient files
For any REPAIR case, we update the client code or configuration, not the environment. For example, case 2’s fix is to explicitly use auth=BearerAuth() or set trust_env=False after weighing proxy impact. We do not delete the user’s real ~/.netrc or force system-wide changes, because other applications might rely on it. Instead, we demonstrate the repair on the specific code:
# Before (case2): raw header got overridden by netrc
# After (repair): add explicit auth parameter
s = Session()
s.trust_env = True
req = Request('GET', url)
prepped = s.prepare_request(req)
resp = s.send(prepped, timeout=5, allow_redirects=False, auth=BearerAuth())
print("Server saw (repaired):", resp.text) # now "bearer-dummy"We retest the affected case(s) and show that now intended and actual align. We keep the baseline case (no-match) to ensure nothing regressed: adding an explicit Bearer in an environment without netrc match still yields Bearer (it does, as expected). We thus repair the caller by making the credential provider explicit.
We emphasize: the repair is to the application’s code/config, not environment. If a real application accidentally sent the wrong credential to a remote system, it should rotate or revoke if needed, but our local loopback lab cannot prove an external exposure. It only shows a logic flaw. So we classify it as a suspected unauthorized send, not a confirmed breach. The owner of the secret would decide next steps.
Choose accept, repair, hold or exposure review
We bind each verdict to concrete evidence:
Accept: For cases 1, 4, 5a, 5b, 6, the actual scheme matches the intended provider and environment (Bearer->Bearer or Basic->Basic as designed). No action needed beyond documentation.
Repair: Case 2 (intended Bearer, got netrc Basic) needs code change. Case 3 (auth=()) also fails contract; in practice we would fix code to remove the empty tuple or handle auth explicitly. We do not “accept” it or ignore it.
Hold/Review: Case 3 is ambiguous (empty auth); we classify it as hold for clarity. We would involve the developer to correct the approach. If this scenario had real credentials, we’d treat it as a misconfiguration needing fix, but not necessarily a leak (no real secret left environment).
Contain/Incident: None of our synthetic tests actually connect to anything except the loopback. Even if a wrong credential were “sent,” it’s only to localhost. In a real setting, if we saw the wrong remote and some real secret had left, we would stop further requests and convene the security team. Our lab results are evidence of a bug, but by design there’s no exfiltration. We therefore say: if this happened with real data, do not declare a breach from this test alone; the security owner would review logs and context. For our purposes, we just label the case as REPAIR or HOLD, and note that any credentials (lab-pass/token) were synthetic dummy values.
We do not “contain” our lab outcome beyond our process. However, in production, if the wrong credential goes to an unapproved host, that is real exposure. The playbook here is: stop any automated retries, gather provenance logs (which include neither secrets nor full headers in our report, just labels and sources), and let security owners decide rotation. The (fake) endpoint only confirmed which scheme arrived; it never echoed the secret. We never log raw tokens.
Assign credential lifecycle and configuration ownership
Each layer has an owner:
Caller code/maintainer: Responsible for “intended provider”. They sign the contract “this request uses Bearer lab-token-not-a-secret”. The developer is responsible for keeping that contract: e.g. fixing code when it breaks (Repair case). They should also know where the secret comes from and where it’s allowed to go.
Network/TLS config owner: Manages proxies, CA bundles, etc. The trust boundary here is whether env settings should be trusted. As we saw, disabling trust_env affects proxies. So if we did that fix, we’d coordinate with the network admin to ensure no enterprise proxy is needed, or to bake proxy config into code. That admin would update deployment pipelines or container configs to match.
Security reviewer: Audits after any library or config change. For example, after upgrading Requests or modifying trust_env, the security team should review that all credentials are still handled safely. They use this lab’s evidence (like “Request X with auth Provider=BearerAuth succeeded, case Y was overwritten by netrc which we fixed”) as part of an audit trail.
We recommend routine revalidation when any relevant change happens: library (Requests) upgrade, OS change of home directory, or new proxy policy could alter which auth is used. Rotation of credentials should follow the usual schedule, independent of this test. The test only covers the client-side header choice, not the secret itself.
Build broader secure-workflow skills with Refonte Learning
This credential-provenance test is one piece of a mature API security practice. Refonte Learning’s Cybersecurity & DevSecOps program provides a broader foundation in secure workflows, threat modeling and incident response, which underpin the principles here. It covers how to design secure authentication (API tokens, OAuth, etc.), model threats like accidental exposure, and automate checks like this one. While this article focused on the Requests library’s authentication and environment-handling behavior, the program emphasizes a systematic approach: classify your data flows, assign clear ownership, and verify at each stage.
By following the procedure above, a team can ensure “what we wrote is what we sent.” Always verify Authorization headers in a controlled test environment before deploying, and train developers on these precedents. As Refonte’s curriculum underscores, security is about layered controls and auditability, not just one tool. (For example, this lab did not test OAuth token validation or CORS, which are also important topics covered in secure API best practices and observability exercises.)
Prospective students: our hands-on scenarios here are aligned with the kinds of skills practiced in Refonte Learning’s Cybersecurity & DevSecOps program, which spans threat modeling, secure pipeline design, and incident response. This Requests-specific lab is a deep-dive into one niche issue. To gain a well-rounded skill set, consider the Cybersecurity Program for structured learning (though note it does not train on this exact laboratory by name) and broader problem-solving techniques.
