sigv4 is a minimal Python library for signing HTTP requests with AWS
Signature Version 4 and resolving AWS credentials — without pulling in boto3
or botocore.
The goal is to let callers use AWS HTTP APIs directly (via aiohttp, httpx,
requests, or anything else) while this library handles the authentication
plumbing.
Zero Python package dependencies. The library uses only the Python stdlib
(hashlib, hmac, urllib, xml.etree, configparser, threading,
datetime). Installing sigv4 adds nothing to your dependency tree beyond
itself. This is a deliberate constraint — new runtime dependencies require
explicit justification and a strong case.
Correct. The signing implementation is validated against the official AWS SigV4 test suite. See Conformance Testing below.
Lightweight. The signing algorithm itself is pure computation with no I/O. Credential fetching (STS, IMDS, ECS) only happens on first use and on refresh, not on every request.
IRSA is just one of several supported credential sources. The library works
equally well with environment variables, ~/.aws/credentials, ECS task roles,
EC2 instance profiles, or explicit static credentials passed directly to
sign_headers().
src/sigv4/
├── __init__.py # Public API re-exports
├── py.typed # PEP 561 marker (typed package)
├── signing.py # SigV4 algorithm — pure functions, zero I/O
├── credentials.py # Credentials dataclass + RefreshableCredentials
├── resolve.py # resolve_credentials() — the provider chain
├── signer.py # Signer — high-level sign() wrapper
└── providers/
├── env.py # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
├── web_identity.py # IRSA: token file → STS AssumeRoleWithWebIdentity
├── config_file.py # ~/.aws/credentials and ~/.aws/config
├── container.py # ECS task role endpoint
└── imds.py # EC2 Instance Metadata Service (IMDSv2)
Pure function in signing.py. Takes a Credentials dataclass directly.
Does zero I/O. Runs in microseconds. Suitable for callers that manage their
own credentials and need predictable, non-blocking latency.
from sigv4 import Credentials, sign_headers
headers = sign_headers(
method="GET",
url="https://s3.us-east-1.amazonaws.com/my-bucket",
headers={"host": "s3.us-east-1.amazonaws.com"},
body=b"",
region="us-east-1",
service="s3",
credentials=Credentials(access_key="...", secret_key="..."),
)Wraps credential resolution, auto-refresh, and signing into a single object. Most callers should use this.
from sigv4 import Signer
signer = Signer(region="us-east-1", service="s3")
signer.credentials.refresh() # optional pre-warm
headers = signer.sign(method="GET", url="https://s3.us-east-1.amazonaws.com/my-bucket")Implements the AWS Signature Version 4 specification.
All logic is pure Python stdlib (hashlib, hmac, urllib.parse).
Steps per the AWS SigV4 signing elements reference:
- Timestamp —
YYYYMMDDTHHMMSSZformat forX-Amz-Date - Canonical Request — 6 components joined by newline:
- HTTP method (uppercased)
- Canonical URI — path normalized per RFC 3986 (resolve
.and.., collapse//), then percent-encoded - Canonical query string — keys and values sorted lexicographically and percent-encoded
- Canonical headers — lowercased, sorted, whitespace-collapsed
- Blank line
- Signed headers — semicolon-joined sorted lowercase names
- Payload hash — SHA-256 hex of body (
EMPTY_SHA256for empty body)
- String to Sign — algorithm + date + credential scope + SHA-256 of canonical request
- Signing key — four-step HMAC-SHA256 key derivation:
AWS4+secret→ date → region → service →aws4_request - Signature — HMAC-SHA256 of string-to-sign with signing key, hex-encoded
- Authorization header —
AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=…
Headers excluded from signing — the following headers are never included
in the signature. The AWS signing documentation
specifies that hop-by-hop and volatile transport headers mutated by proxies,
load balancers, and distributed system nodes must not be signed:
connection, keep-alive, proxy-authenticate, proxy-authorization, te,
trailer, transfer-encoding, upgrade, user-agent, x-amzn-trace-id.
Additionally, authorization is excluded because it contains the signature
itself, and expect is excluded because the Expect mechanism is hop-by-hop
(RFC 9110 §10.1.1) and may be consumed or modified by intermediaries before
the request reaches AWS.
The signing implementation is validated against the official AWS SigV4 test
suite, vendored from
botocore's test fixtures
at tests/data/aws4_testsuite/.
Each test case provides a raw HTTP request (.req) and expected outputs at
three intermediate stages — canonical request (.creq), string to sign
(.sts), and final Authorization header (.authz) — allowing failures to
be pinpointed to the exact step that diverges from the spec.
The parametrized test runner is at tests/test_aws_test_suite.py. It covers
all test cases in the suite, including cases that botocore itself skips (e.g.
paths with literal spaces and duplicate query parameter keys), which pass here
because the request-line parser and urllib.parse.parse_qsl handle them
correctly.
To keep the vendored suite current, a monthly GitHub Actions workflow sparse-clones botocore upstream and opens a draft PR if any test files have changed.
Frozen dataclass. Immutable — a new instance is created on each refresh.
@dataclass(frozen=True)
class Credentials:
access_key: str
secret_key: str
token: str | None = None # STS session token (IRSA, ECS, IMDS)
expires_at: datetime | None = None # UTC; None for long-lived IAM credsclass CredentialProvider(Protocol):
def load(self) -> Credentials | None: ...Return None if this provider cannot supply credentials in the current
environment (env vars not set, not on EC2, etc.).
Wraps a CredentialProvider with thread-safe lazy fetching and auto-refresh.
Refresh thresholds — chosen to match botocore's documented behaviour and give adequate time for a retry if the first refresh attempt fails:
- Advisory (15 min before expiry): one thread attempts refresh; if it fails, others continue using the still-valid cached credentials
- Mandatory (10 min before expiry): all callers block until fresh credentials are obtained
Observable properties:
is_ready— fetched at least once and not expiredneeds_refresh— in advisory or mandatory windowexpires_at— expiry of current credentials
Pre-warming:
creds = resolve_credentials()
creds.refresh() # fetch now, on your scheduleresolve_credentials() iterates providers in priority order. The first
provider that returns credentials wins; providers that return None or raise
are skipped. IRSA is not required — any provider in the chain can supply
credentials independently.
| # | Provider | Trigger |
|---|---|---|
| 1 | EnvProvider |
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY |
| 2 | WebIdentityProvider |
AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN (IRSA) |
| 3 | ConfigFileProvider |
~/.aws/credentials / ~/.aws/config (see AWS config file docs) |
| 4 | ContainerProvider |
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / AWS_CONTAINER_CREDENTIALS_FULL_URI — container credential endpoint (ECS task IAM roles, EKS Pod Identity) |
| 5 | IMDSProvider |
EC2 instance metadata at 169.254.169.254 (IMDSv2) |
IRSA (IAM Roles for Service Accounts) allows EKS pods to assume IAM roles without long-lived credentials by exchanging a Kubernetes-issued JWT for temporary STS credentials.
Steps per the STS AssumeRoleWithWebIdentity API:
- Read
AWS_WEB_IDENTITY_TOKEN_FILEpath andAWS_ROLE_ARN - Open the token file and read the JWT — re-read on every refresh, because Kubernetes rotates projected service account tokens before they expire (typically at 80% of their lifetime). Caching the file contents would cause signing failures after rotation.
- POST to STS
AssumeRoleWithWebIdentitywith the JWT — no AWS credentials needed, the signed JWT is the proof of identity - Parse the XML response with
xml.etree.ElementTree(stdlib) - Return a
Credentialswith the temporaryAccessKeyId,SecretAccessKey,SessionToken, andExpirationfrom the STS response
The Expiration returned by STS drives RefreshableCredentials' advisory and
mandatory refresh thresholds, ensuring credentials are renewed before they
expire and before Kubernetes rotates the token file.
Python package dependencies: none — pure stdlib only (hashlib, hmac,
urllib, xml.etree, configparser, threading, datetime)
Dev: pytest, mypy, ruff
This library handles AWS credentials. Any code path that could emit credential material — through logging, print statements, exception messages, or string representations — is a security defect.
Principle: if it cannot be determined from a cursory analysis of the source that no credentials are leaked, the construct is banned.
The following rules are enforced by scripts/check-no-credential-leaks.py, an
AST-based CI check that cannot be suppressed by # noqa or # type: ignore:
-
print()is banned. Noprint()calls anywhere in library source. -
Exception messages must be string literals. No f-strings,
.format(), string concatenation, or variable references in exception constructors. A user-provided URL, for example, could contain a credential embedded in the query string — echoing it back in an exception leaks it. Messages should tell the user what to check, not repeat what they provided. -
raise ... from <exception>is banned (exceptfrom None). Chained exceptions can contain credential data in their__str__— e.g.,json.JSONDecodeErrorincludes a snippet of the decoded input,urllib.error.HTTPErrorcan include the response body. Usefrom Noneor omit the cause entirely. -
Only
SigV4Errorand its subclasses may be raised.RuntimeErrorand other built-in exceptions are banned.SigV4Error.__init__accepts only aLiteralString, enforced by mypy at type-check time. -
Logging is restricted to a single internal module (
_log.py). No other module may importloggingor calllogging.*/logger.*directly. The public API issigv4._log.warning(message: LiteralString)— onlywarninglevel is exposed, and only string literals are accepted. This prevents variable data (which could contain credentials) from ever being logged. An environment variable value, for instance, could be a URL with a credential in it — the warning message should describe the problem in general terms, not echo the value back. -
Credentials.__repr__and__str__are fully redacted. Even if aCredentialsobject is accidentally passed to a log or exception, no secret material is visible. -
# type: ignoreis banned from all source files undersrc/. This prevents suppressing theLiteralStringconstraint onSigV4Errorandwarning(), and prevents bypassing any other mypy check. If mypy reports an error, fix the code.
- AWS Signature Version 4 — Create a signed request
- AWS Signature Version 4 — Signing elements
- STS AssumeRoleWithWebIdentity
- EKS — IAM roles for service accounts (IRSA)
- Kubernetes — Projected service account token rotation
- EC2 — Instance Metadata Service (IMDSv2)
- ECS — Task IAM roles
- AWS CLI — Configuration and credential files
- RFC 3986 — URI generic syntax
- AWS SigV4 test suite (vendored from botocore)