Bearer token authentication is the mechanism behind nearly every modern API call. You send a token in the request header, the server validates it, and either grants or denies access. In Python, the Requests library makes this process straightforward -- but the difference between a quick script and a production-ready implementation lies in how you manage sessions, handle token expiration, and deal with failed requests. This guide covers everything from your first authenticated call to building a reusable client with automatic refresh and retry logic.
Bearer authentication gets its name from the concept that whoever "bears" (holds) the token is granted access. The token is typically a JWT or an opaque string issued by an OAuth 2.0 authorization server, an API provider's dashboard, or a custom authentication endpoint. The client includes this token in the Authorization header of every HTTP request using the format Authorization: Bearer <token>. RFC 6750 Section 2.1 designates this header method as the recommended approach, and resource servers are required to support it. The server extracts the token, validates it, and processes the request if the token is legitimate.
What Bearer Token Authentication Is
Bearer tokens are a category of access token defined in RFC 6750 (October 2012) as part of the OAuth 2.0 framework. The specification states that any party in possession of a bearer token can use it to access the associated resources without demonstrating possession of a cryptographic key. That simplicity makes bearer tokens convenient to use, but it also means that anyone who obtains your token can make requests on your behalf until the token expires or is revoked.
In practice, you will encounter bearer tokens in two common scenarios. The first is when a third-party API (like GitHub, Stripe, or a weather service) provides a static API token through their developer dashboard. The second is when your application obtains a short-lived access token through an OAuth 2.0 flow, which needs to be refreshed periodically. Both scenarios use the same Authorization: Bearer header format -- the difference is in how you obtain and manage the token.
Making Your First Authenticated Request
The most direct way to send a bearer token with Python Requests is to include it in the headers dictionary. This approach works well for quick scripts and one-off API calls.
import requests
import os
API_TOKEN = os.environ["API_TOKEN"]
BASE_URL = "https://api.example.com/v1"
# Send a GET request with a Bearer token
response = requests.get(
f"{BASE_URL}/users/me",
headers={"Authorization": f"Bearer {API_TOKEN}"},
)
response.raise_for_status()
print(response.json())
# Send a POST request with the same token
data = {"name": "New Project", "status": "active"}
response = requests.post(
f"{BASE_URL}/projects",
headers={
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
},
json=data,
)
response.raise_for_status()
print(response.json())
This works, but notice that the Authorization header has to be repeated in every call. If you are making multiple requests to the same API -- which is nearly always the case -- this repetition is both tedious and error-prone. A better approach is to use a session.
Using Sessions for Persistent Authentication
A requests.Session object persists headers, cookies, and connection settings across multiple requests. By setting the Authorization header once on the session, every subsequent request automatically includes it. As the Requests advanced usage documentation confirms, sessions also reuse underlying TCP connections through HTTP Keep-Alive, which reduces latency when making multiple calls to the same server.
import requests
import os
API_TOKEN = os.environ["API_TOKEN"]
# Create a session with persistent auth headers
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {API_TOKEN}",
"Accept": "application/json",
})
# All requests through this session are authenticated
users = session.get("https://api.example.com/v1/users")
projects = session.get("https://api.example.com/v1/projects")
print(users.json())
print(projects.json())
# Use a context manager to ensure cleanup
with requests.Session() as s:
s.headers["Authorization"] = f"Bearer {API_TOKEN}"
response = s.get("https://api.example.com/v1/data")
print(response.json())
Using a with statement (context manager) with requests.Session() ensures that the underlying connection pool is properly released when you are done, even if an exception occurs.
Building a Reusable Custom Auth Class
The Requests library provides an AuthBase class that lets you create reusable authentication handlers. As the official Requests authentication documentation explains, you subclass AuthBase and implement the __call__ method, which is invoked during request setup. This creates a component that can be assigned to any session's auth property or passed to individual requests. It separates authentication logic from your business code and makes it easy to swap authentication mechanisms later.
from requests.auth import AuthBase
class BearerTokenAuth(AuthBase):
"""Attach a Bearer token to every request."""
def __init__(self, token):
self.token = token
def __call__(self, request):
request.headers["Authorization"] = f"Bearer {self.token}"
return request
# Usage with individual requests
import requests
import os
auth = BearerTokenAuth(os.environ["API_TOKEN"])
response = requests.get(
"https://api.example.com/v1/users",
auth=auth,
)
print(response.json())
# Usage with a session (applied to all requests)
session = requests.Session()
session.auth = auth
response = session.get("https://api.example.com/v1/projects")
print(response.json())
This pattern is preferable to setting headers directly because it keeps the token management logic in a single, testable class. It also follows the Requests library's intended design -- the auth parameter is called for every request, which becomes important when you add token refresh logic.
Automatic Token Refresh on Expiration
When working with OAuth 2.0 access tokens that expire after a set period (commonly 30 to 60 minutes, though it varies by provider -- Google issues 60-minute tokens, while Auth0 defaults to 24 hours), your application needs to detect expiration and obtain a new token without manual intervention. The cleanest way to handle this is to extend the custom AuthBase class with refresh logic that runs before each request.
import time
import requests
from requests.auth import AuthBase
class AutoRefreshBearerAuth(AuthBase):
"""Bearer auth with automatic token refresh."""
def __init__(self, token_url, client_id, client_secret):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.access_token = None
self.expires_at = 0
def _fetch_token(self):
"""Request a new token from the auth server."""
response = requests.post(
self.token_url,
data={
"grant_type": "client_credentials",
"scope": "read write",
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
expires_in = data.get("expires_in", 3600)
# Refresh 60 seconds early to avoid edge cases
self.expires_at = time.time() + expires_in - 60
def __call__(self, request):
if time.time() >= self.expires_at:
self._fetch_token()
request.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
return request
# Usage: the token is fetched and refreshed automatically
auth = AutoRefreshBearerAuth(
token_url="https://auth.example.com/oauth/token",
client_id="your-client-id",
client_secret="your-client-secret",
)
session = requests.Session()
session.auth = auth
# These calls handle token management transparently
data = session.get("https://api.example.com/v1/resources")
print(data.json())
Because __call__ runs before every request, the token check happens automatically. Your application code never needs to think about expiration -- it simply makes requests through the session and the auth class handles the rest.
The AutoRefreshBearerAuth class shown above is not thread-safe. If your application makes concurrent requests from multiple threads, two threads could simultaneously detect an expired token and both attempt to refresh it. To handle this in multithreaded code, wrap the token refresh logic in a threading.Lock. This is not necessary for single-threaded scripts or applications using asyncio for concurrency.
Adding Retry Logic with HTTPAdapter
Network failures, temporary server errors, and rate limits are facts of life when working with APIs. Rather than wrapping every request in a try/except loop, you can attach a retry strategy to your session using HTTPAdapter from the Requests library and the Retry class from urllib3. This combination automatically retries failed requests with exponential backoff.
The allowed_methods parameter shown below is the correct name for urllib3 v2.x and later. If you are using the older urllib3 v1.x (bundled with Requests versions before 2.28), use method_whitelist instead. You can check your version with import urllib3; print(urllib3.__version__).
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def create_resilient_session(auth=None):
"""Create a session with retry logic and auth."""
session = requests.Session()
retry_strategy = Retry(
total=3,
backoff_factor=1, # 1s, 2s, 4s between retries
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST", "PUT", "DELETE"],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("https://", adapter)
session.mount("http://", adapter)
if auth:
session.auth = auth
return session
# Combine retry logic with auto-refresh auth
auth = AutoRefreshBearerAuth(
token_url="https://auth.example.com/oauth/token",
client_id="your-client-id",
client_secret="your-client-secret",
)
session = create_resilient_session(auth=auth)
# This request will auto-refresh tokens AND retry on failure
response = session.get(
"https://api.example.com/v1/resources",
timeout=10,
)
print(response.json())
The backoff_factor controls the delay between retries. According to the urllib3 documentation, the formula is: {backoff_factor} * (2 ** ({number of previous retries})) seconds, applied after the second try. With a factor of 1, retries sleep for approximately 1 second, 2 seconds, and 4 seconds. The delay is capped at backoff_max (120 seconds by default). This prevents overwhelming a server that is already under stress.
Error Handling for Authenticated Requests
A robust API client handles authentication errors specifically, distinguishing them from other types of failures. A 401 response means the token is invalid or expired. A 403 means the token is valid but lacks the required permissions. Other status codes indicate server or network issues unrelated to authentication.
import requests
def make_authenticated_request(session, method, url, **kwargs):
"""Make a request with structured error handling."""
try:
response = session.request(
method, url, timeout=10, **kwargs
)
if response.status_code == 401:
raise PermissionError(
"Authentication failed. Token may be "
"expired or invalid."
)
if response.status_code == 403:
raise PermissionError(
"Access denied. Insufficient permissions "
f"for {method.upper()} {url}."
)
if response.status_code == 429:
retry_after = response.headers.get(
"Retry-After", "unknown"
)
raise RuntimeError(
f"Rate limited. Retry after {retry_after}s."
)
response.raise_for_status()
return response
except requests.exceptions.ConnectionError:
raise ConnectionError(
f"Could not connect to {url}. "
"Check your network connection."
)
except requests.exceptions.Timeout:
raise TimeoutError(
f"Request to {url} timed out after 10s."
)
Secure Token Storage Beyond Environment Variables
The common advice is "use environment variables," and that is a reasonable starting point for local development. But environment variables have real limitations in production: they are visible to every process running under the same user, they can leak through child processes, crash dumps, or container inspection commands like docker inspect, and they are not audited or rotated automatically. For anything beyond a development machine, a dedicated secrets manager is the better choice. For a broader look at secure Python coding practices, including credential handling in production applications, see our dedicated guide.
Cloud providers each offer a managed secrets service: AWS Secrets Manager, Google Cloud Secret Manager, and Azure Key Vault. These tools encrypt secrets at rest, enforce access control through IAM policies, provide audit logs for every access, and support automatic rotation on a schedule you define. The pattern for using them in Python follows the same shape regardless of provider -- you call the secrets API at application startup (or on first use), retrieve the token, and pass it into your auth class.
import boto3
import json
def get_secret(secret_name, region="us-east-1"):
"""Retrieve a secret from AWS Secrets Manager."""
client = boto3.client(
"secretsmanager", region_name=region
)
response = client.get_secret_value(
SecretId=secret_name
)
return json.loads(response["SecretString"])
# Retrieve credentials at startup, not per-request
credentials = get_secret("myapp/api-credentials")
auth = AutoRefreshBearerAuth(
token_url=credentials["token_url"],
client_id=credentials["client_id"],
client_secret=credentials["client_secret"],
)
For local development where a full secrets manager is overkill, .env files loaded through python-dotenv are a practical middle ground. The key rule is that .env files must be listed in .gitignore and should never be committed to version control. Treat any file containing secrets the same way you would treat a private key.
If you are running in a CI/CD pipeline, avoid storing tokens as plaintext pipeline variables. Tools like GitHub Actions support encrypted secrets that are masked in logs and scoped to specific repositories or environments. GitLab CI, CircleCI, and Jenkins all provide similar mechanisms.
Safe Logging: Redacting Tokens from Output
When debugging API calls, the natural instinct is to log request headers. The problem is that the Authorization header contains your bearer token in plain text, and log files tend to persist far longer than anyone expects. A token that appears in a log file stored on a shared server, a monitoring dashboard, or a crash report becomes a credential that anyone with log access can use.
The solution is to build a logging filter that intercepts sensitive headers before they reach the log output. Python's logging module supports custom filters that can modify log records in-flight.
import logging
import re
class TokenRedactFilter(logging.Filter):
"""Redact bearer tokens from log messages."""
_pattern = re.compile(
r"(Bearer\s+)\S+", re.IGNORECASE
)
def filter(self, record):
if isinstance(record.msg, str):
record.msg = self._pattern.sub(
r"\1[REDACTED]", record.msg
)
if record.args:
record.args = tuple(
self._pattern.sub(r"\1[REDACTED]", str(a))
if isinstance(a, str) else a
for a in record.args
)
return True
# Attach the filter to all handlers
logger = logging.getLogger()
for handler in logger.handlers:
handler.addFilter(TokenRedactFilter())
# Now safe to log request details
logging.info(
"Request headers: %s",
{"Authorization": "Bearer eyJhbGciOi..."},
)
# Output: Request headers: {'Authorization': 'Bearer [REDACTED]'}
This filter catches any string matching the Bearer ... pattern and replaces the token value with [REDACTED] before the message is written. Attach it to your root logger so it covers every handler -- console, file, and any third-party log aggregators.
If you enable debug-level logging for the urllib3 or requests libraries, they will print full request and response details including headers. The redaction filter shown above will catch bearer tokens in those messages too, as long as it is applied before the debug output is written. Always test your logging configuration to confirm that tokens do not appear in any output.
Paginating Through Authenticated Endpoints
Many APIs return results in pages rather than as a single response. When you combine pagination with bearer token authentication, the session-based approach pays off immediately -- the auth header carries forward on every page request without any extra code. The two common pagination patterns are offset-based (using page and per_page parameters) and cursor-based (where each response includes a URL or token pointing to the next batch).
def paginate(session, url, params=None):
"""Yield all items across paginated responses."""
params = params or {}
while url:
response = session.get(
url, params=params, timeout=10
)
response.raise_for_status()
data = response.json()
yield from data.get("results", [])
# Cursor-based: follow the 'next' link
url = data.get("next")
# Clear params after first request since
# the 'next' URL includes them already
params = {}
# Usage with an authenticated session
session = create_resilient_session(auth=auth)
all_users = list(
paginate(session, "https://api.example.com/v1/users")
)
print(f"Retrieved {len(all_users)} users")
Because the session carries both the auth handler and the retry adapter, every page request benefits from automatic token refresh and exponential backoff. If a token expires mid-pagination, the AutoRefreshBearerAuth class handles it transparently -- the pagination function never needs to know about authentication at all.
Testing Authenticated Code Without Real Tokens
Production API clients need tests, but tests should never call real APIs or use real tokens. The standard approach is to mock the HTTP layer so that your auth class, retry logic, and error handling can be verified without network access. The responses library (or unittest.mock) lets you register fake endpoints that return predefined data. If you are new to testing in Python, our guide on Python unit testing with unittest and pytest covers the fundamentals from the ground up.
# pip install responses
import responses
import requests
from your_module import (
AutoRefreshBearerAuth,
create_resilient_session,
)
@responses.activate
def test_auto_refresh_on_expiry():
"""Verify that the auth class fetches a new
token when the current one has expired."""
# Mock the token endpoint
responses.post(
"https://auth.example.com/oauth/token",
json={
"access_token": "fresh-token-abc",
"expires_in": 3600,
},
)
# Mock the API endpoint
responses.get(
"https://api.example.com/v1/data",
json={"status": "ok"},
)
auth = AutoRefreshBearerAuth(
token_url="https://auth.example.com/oauth/token",
client_id="test-id",
client_secret="test-secret",
)
session = create_resilient_session(auth=auth)
response = session.get(
"https://api.example.com/v1/data"
)
assert response.status_code == 200
# Verify the token was sent in the header
api_call = responses.calls[1]
assert "Bearer fresh-token-abc" in (
api_call.request.headers["Authorization"]
)
This test confirms that the auth class calls the token endpoint, receives a token, and attaches it to the subsequent API request -- all without touching a real server. You can extend this pattern to test 401 retry flows, expired token scenarios, and rate-limit handling by registering responses with specific status codes.
When to Move Beyond Requests: The Async Question
The Requests library is synchronous. Every call to session.get() blocks the thread until the response arrives. For scripts, CLI tools, and applications that make a handful of API calls, this is perfectly fine. But if your application needs to make tens or hundreds of concurrent API calls -- fetching data from multiple endpoints simultaneously, or processing webhook queues -- synchronous requests become a bottleneck. If the async/await pattern is unfamiliar, our article on coroutines in Python explains the underlying mechanics.
HTTPX is the most direct upgrade path. It provides both synchronous and asynchronous clients with an API that mirrors Requests closely, supports HTTP/2 natively, and includes its own Auth class for custom authentication handlers. A bearer token auth class in HTTPX follows the same concept as the Requests AuthBase approach, but uses a generator-based flow instead of a __call__ method.
import httpx
import asyncio
class BearerAuth(httpx.Auth):
"""Bearer token auth for HTTPX (sync and async)."""
def __init__(self, token):
self.token = token
def auth_flow(self, request):
request.headers["Authorization"] = (
f"Bearer {self.token}"
)
yield request
# Async usage with connection pooling
async def fetch_all(urls, token):
auth = BearerAuth(token)
async with httpx.AsyncClient(auth=auth) as client:
tasks = [
client.get(url, timeout=10.0)
for url in urls
]
return await asyncio.gather(*tasks)
# All requests run concurrently, each authenticated
urls = [
"https://api.example.com/v1/users",
"https://api.example.com/v1/projects",
"https://api.example.com/v1/reports",
]
results = asyncio.run(fetch_all(urls, "your-token"))
The Requests library remains the right choice for the majority of Python API work: it is stable, battle-tested, and has the largest ecosystem of tutorials and third-party integrations. Consider HTTPX when you need async/await concurrency, HTTP/2 multiplexing, or when you are already working within an async framework like FastAPI or Starlette. The authentication concepts -- sessions, custom auth classes, token refresh, retry logic -- transfer directly between the two libraries.
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Token in URL parameters | Tokens in query strings appear in server logs, browser history, and referrer headers | Always send tokens in the Authorization header, never as a URL parameter |
| No timeout set | Requests hang indefinitely if the server does not respond | Always pass timeout=10 (or appropriate value) to every request |
| Hard-coded tokens in source | Tokens committed to Git are exposed in repository history | Load tokens from environment variables or a secrets manager |
| Ignoring HTTP status codes | Silently processing error responses leads to corrupted data | Call response.raise_for_status() or check status codes explicitly |
| Logging tokens during debugging | Tokens in log files become a persistent security exposure | Redact tokens in logs; print only the first few characters if needed |
| Authorization header stripped on redirect | urllib3's Retry class removes the Authorization header on redirects by default (via remove_headers_on_redirect). Your token silently disappears after a 301/302, causing unexpected 401 errors at the final destination |
Be aware of this behavior when debugging redirect chains. If the redirect stays within the same trusted domain, you can customize remove_headers_on_redirect in your Retry configuration |
Never send bearer tokens over plain HTTP. Always use HTTPS. RFC 6750 mandates the use of TLS for bearer token transmission, stating that tokens must be protected from disclosure in storage and in transport. Without TLS encryption, anyone monitoring the network can intercept the token and impersonate your application. The Requests library verifies TLS certificates by default -- do not disable this with verify=False in production.
Key Takeaways
- Use sessions for repeated API calls: A
requests.Sessionpersists theAuthorizationheader across all requests and reuses TCP connections, reducing both code duplication and network latency. - Build a custom AuthBase class for reusability: Subclassing
requests.auth.AuthBasecreates a self-contained authentication handler that can be assigned to any session or individual request. This is the Requests library's intended pattern for token-based auth. - Automate token refresh in the auth class: By checking the token's expiration time inside the
__call__method, the auth class transparently fetches a new token before it expires. Your application code never needs to manage token lifecycle directly. - Add retry logic with HTTPAdapter: Mount an
HTTPAdapterconfigured with aRetrystrategy to your session to handle transient failures, server errors, and rate limits with exponential backoff -- without wrapping every call in try/except loops. - Store tokens in a secrets manager, not just environment variables: Environment variables work for local development, but production systems need the encryption, access control, and audit logging that a dedicated secrets manager provides.
- Redact tokens from all log output: A single logging filter applied to your root logger prevents bearer tokens from appearing in console output, log files, or monitoring dashboards.
- Test auth logic without real tokens: Use mocking libraries like
responsesto simulate token endpoints and API responses. This lets you verify refresh flows, retry behavior, and error handling without network access. - Always use HTTPS and set timeouts: Bearer tokens are only as secure as the channel they travel over. Use HTTPS exclusively, set explicit timeouts on every request, and never expose tokens in URLs, query strings, or unredacted log files.
Bearer token authentication with the Requests library scales from a single API call in a script to a production client that manages thousands of requests per day. The progression is natural: start with a simple header, move to a session when you need persistence, build a custom auth class when you need reusability, add refresh logic when tokens expire, layer on retry strategies when reliability matters, and lock down token storage and logging when the stakes are real. Each step builds on the last, and the Requests library's design supports every stage. When you eventually need async concurrency, the same patterns transfer directly to HTTPX.
Sources and References
- RFC 6750 -- M. Jones, D. Hardt. "The OAuth 2.0 Authorization Framework: Bearer Token Usage." Internet Engineering Task Force (IETF), October 2012. datatracker.ietf.org/doc/html/rfc6750
- Requests Library -- Authentication. Official documentation covering AuthBase, custom authentication handlers, and OAuth integration. requests.readthedocs.io
- Requests Library -- Advanced Usage. Official documentation on sessions, transport adapters, and prepared requests. requests.readthedocs.io
- urllib3 -- Retry Class Reference. Official urllib3 documentation covering Retry parameters including backoff_factor, status_forcelist, allowed_methods, and remove_headers_on_redirect. urllib3.readthedocs.io
- RFC 6749 -- D. Hardt, Ed. "The OAuth 2.0 Authorization Framework." Internet Engineering Task Force (IETF), October 2012. datatracker.ietf.org/doc/html/rfc6749
- RFC 9700 -- "OAuth 2.0 Security Best Current Practice." Internet Engineering Task Force (IETF), January 2025. Provides updated guidance on token lifetimes, sender-constraining, and refresh token security. datatracker.ietf.org/doc/html/rfc9700
- HTTPX -- Authentication. Official HTTPX documentation covering custom Auth classes, sync and async auth flows, and token refresh patterns. python-httpx.org
- OWASP Secrets Management Cheat Sheet. Best practices for managing API keys, tokens, and credentials in application code and CI/CD pipelines. cheatsheetseries.owasp.org
- responses Library. Utility library for mocking out the Python Requests library in tests. github.com/getsentry/responses