Loading learning content...
API keys answer the question "who is making this request?" but they don't answer a more critical question: "has this request been tampered with?" An API key transmitted over the network could be intercepted and reused. A request body could be modified in transit. The timestamp could be replayed from a previous valid request.
HMAC (Hash-based Message Authentication Code) solves these problems by cryptographically binding the request content to the authentication credential. With HMAC, even if an attacker intercepts a valid request, they cannot modify it without detection, and they cannot forge new requests without possessing the secret key.
HMAC is the authentication backbone of critical financial APIs (Stripe, Plaid), cloud provider APIs (AWS Signature Version 4), and webhook verification systems across the industry. Understanding HMAC is essential for building or integrating with secure APIs.
By the end of this page, you will understand the cryptographic foundations of HMAC, how to construct canonical requests for signing, implementation patterns across different programming languages, common pitfalls and their mitigations, and how major platforms implement HMAC authentication. You'll be able to design and implement production-grade HMAC authentication systems.
HMAC combines a cryptographic hash function (like SHA-256) with a secret key to produce a message authentication code. Unlike simple hashing, HMAC proves that:
The HMAC Algorithm:
HMAC is defined as:
HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))
Where:
K is the secret keym is the messageH is the hash function (SHA-256, SHA-512, etc.)K' is the key padded to the block sizeopad is the outer padding (0x5c repeated)ipad is the inner padding (0x36 repeated)|| denotes concatenation⊕ denotes XORThe double-hashing structure (inner and outer) provides security properties that simple hash(key || message) constructions lack, specifically resistance to length extension attacks.
While understanding the algorithm is important, never implement HMAC from scratch. Use your platform's standard library: Python's hmac module, Go's crypto/hmac, Node's crypto.createHmac(). These implementations are audited, tested, and resistant to timing attacks.
| Algorithm | Output Size | Security Level | Performance | Recommendation |
|---|---|---|---|---|
| HMAC-MD5 | 128 bits | Deprecated | Fast | Never use - collision attacks possible |
| HMAC-SHA1 | 160 bits | Weak | Fast | Legacy only - not recommended for new systems |
| HMAC-SHA256 | 256 bits | Strong | Good | Recommended default for most use cases |
| HMAC-SHA384 | 384 bits | Very Strong | Slower | Use when longer output needed |
| HMAC-SHA512 | 512 bits | Very Strong | Varies* | 64-bit systems may be faster than SHA-256 |
| HMAC-SHA3-256 | 256 bits | Strong | Slower | Alternative if SHA-2 concerns arise |
Why HMAC Over Simple Hash + Key?
A naive approach might be: hash(secret + message). This is vulnerable to length extension attacks where an attacker can compute hash(secret + message + attacker_data) without knowing the secret, by using the hash output as an intermediate state.
HMAC's nested structure prevents length extension because:
This is why financial APIs, cloud providers, and security-critical systems universally use proper HMAC rather than simpler constructions.
The most critical aspect of HMAC authentication is constructing the canonical request—a deterministic, reproducible representation of the HTTP request that both client and server will sign identically.
Why Canonicalization Matters:
HTTP requests can be represented in many equivalent ways:
%20 vs + for spaces)\n vs \r\n)Without strict canonicalization, client and server will compute different signatures for semantically identical requests, causing authentication failures.
Canonical Request Structure:
Most HMAC authentication schemes (AWS SigV4, Stripe, etc.) use a structure like:
CanonicalRequest = HTTPMethod + '\n' +
CanonicalURI + '\n' +
CanonicalQueryString + '\n' +
CanonicalHeaders + '\n' +
SignedHeaders + '\n' +
HashedPayload
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
import hashlibimport hmacimport urllib.parsefrom datetime import datetime, timezonefrom typing import Dict, List, Optionalfrom dataclasses import dataclass @dataclassclass HTTPRequest: method: str path: str query_params: Dict[str, str] headers: Dict[str, str] body: Optional[bytes] = None class CanonicalRequestBuilder: """ Builds canonical requests following AWS SigV4-style patterns. This is the industry-standard approach used by major APIs. """ # Headers that should always be signed REQUIRED_SIGNED_HEADERS = ['host', 'x-timestamp'] # Headers that should never be signed (ephemeral/transport) EXCLUDED_HEADERS = ['authorization', 'x-amz-signature', 'user-agent'] def __init__(self, signed_headers: Optional[List[str]] = None): """ Args: signed_headers: Specific headers to include in signature. If None, all headers except excluded ones are signed. """ self.signed_headers = signed_headers def build(self, request: HTTPRequest) -> str: """ Build the canonical request string. Returns a deterministic string representation that can be reproduced exactly by both client and server. """ # 1. HTTP Method (uppercase) method = request.method.upper() # 2. Canonical URI (path component, URL-encoded) canonical_uri = self._canonicalize_uri(request.path) # 3. Canonical Query String (sorted, URL-encoded) canonical_query = self._canonicalize_query_string(request.query_params) # 4. Canonical Headers (lowercase, sorted, trimmed) signed_header_names, canonical_headers = self._canonicalize_headers( request.headers ) # 5. Signed Headers List (semicolon-separated) signed_headers_str = ';'.join(signed_header_names) # 6. Hashed Payload (SHA-256 of body) hashed_payload = self._hash_payload(request.body) # Combine all components canonical_request = '\n'.join([ method, canonical_uri, canonical_query, canonical_headers, signed_headers_str, hashed_payload, ]) return canonical_request def _canonicalize_uri(self, path: str) -> str: """ Canonicalize the URI path component. - Empty path becomes '/' - Path segments are URL-encoded - Multiple slashes are NOT collapsed (unlike some systems) """ if not path: return '/' # URL-encode each path segment segments = path.split('/') encoded_segments = [ urllib.parse.quote(segment, safe='~') for segment in segments ] canonical_path = '/'.join(encoded_segments) # Ensure leading slash if not canonical_path.startswith('/'): canonical_path = '/' + canonical_path return canonical_path def _canonicalize_query_string( self, params: Dict[str, str] ) -> str: """ Canonicalize query string parameters. - Sort by parameter name (code point order) - URL-encode both names and values - Empty values are preserved as 'key=' """ if not params: return '' # Sort by key, then encode sorted_params = sorted(params.items()) encoded_pairs = [ f"{urllib.parse.quote(k, safe='~')}={urllib.parse.quote(v, safe='~')}" for k, v in sorted_params ] return '&'.join(encoded_pairs) def _canonicalize_headers( self, headers: Dict[str, str] ) -> tuple: """ Canonicalize headers. - Convert names to lowercase - Trim whitespace from values - Collapse multiple spaces to single space - Sort by header name Returns: Tuple of (sorted_header_names, canonical_headers_string) """ canonical = {} for name, value in headers.items(): lower_name = name.lower() # Skip excluded headers if lower_name in self.EXCLUDED_HEADERS: continue # If specific headers requested, only include those if self.signed_headers and lower_name not in self.signed_headers: continue # Normalize value: trim and collapse whitespace normalized_value = ' '.join(value.split()) canonical[lower_name] = normalized_value # Sort by header name sorted_names = sorted(canonical.keys()) # Build canonical headers string header_lines = [ f"{name}:{canonical[name]}" for name in sorted_names ] # Each header on own line, blank line at end canonical_headers = '\n'.join(header_lines) + '\n' return sorted_names, canonical_headers def _hash_payload(self, body: Optional[bytes]) -> str: """ Compute SHA-256 hash of the request body. Empty body has a specific hash value. """ if body is None: body = b'' return hashlib.sha256(body).hexdigest() def compute_signature( self, request: HTTPRequest, secret_key: bytes, timestamp: datetime, ) -> str: """ Compute the full HMAC signature for a request. """ # Build canonical request canonical_request = self.build(request) # Hash the canonical request canonical_request_hash = hashlib.sha256( canonical_request.encode('utf-8') ).hexdigest() # Build string to sign string_to_sign = '\n'.join([ 'HMAC-SHA256', # Algorithm identifier timestamp.strftime('%Y%m%dT%H%M%SZ'), # ISO8601 timestamp canonical_request_hash, ]) # Compute HMAC signature signature = hmac.new( secret_key, string_to_sign.encode('utf-8'), hashlib.sha256 ).hexdigest() return signature # Example usageif __name__ == "__main__": builder = CanonicalRequestBuilder() request = HTTPRequest( method='POST', path='/v1/charges', query_params={'expand[]': 'customer'}, headers={ 'Host': 'api.example.com', 'Content-Type': 'application/json', 'X-Timestamp': '2024-01-15T10:30:00Z', }, body=b'{"amount": 1000, "currency": "usd"}', ) canonical = builder.build(request) print("Canonical Request:") print(canonical) print() signature = builder.compute_signature( request, secret_key=b'sk_live_very_secret_key_here', timestamp=datetime.now(timezone.utc), ) print(f"Signature: {signature}")The most common HMAC integration bugs come from canonicalization differences: newline handling (\n vs \r\n), URL encoding variations (space as + vs %20), and header capitalization. Always test with your API provider's signature debugging tools before going to production.
Let's implement a complete HMAC authentication system, covering both client-side signature generation and server-side verification.
The Complete Flow:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
package hmacauth import ( "bytes" "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "io" "net/http" "sort" "strings" "time") const ( // Header names HeaderSignature = "X-Signature" HeaderTimestamp = "X-Timestamp" HeaderKeyID = "X-API-Key-ID" // Timestamp tolerance (±5 minutes) TimestampTolerance = 5 * time.Minute // Algorithm identifier Algorithm = "HMAC-SHA256") // SigningError represents an error during signature operationstype SigningError struct { Code string Message string} func (e *SigningError) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message)} var ( ErrMissingSignature = &SigningError{"MISSING_SIGNATURE", "signature header required"} ErrMissingTimestamp = &SigningError{"MISSING_TIMESTAMP", "timestamp header required"} ErrInvalidTimestamp = &SigningError{"INVALID_TIMESTAMP", "timestamp format invalid"} ErrTimestampExpired = &SigningError{"TIMESTAMP_EXPIRED", "request timestamp outside valid window"} ErrInvalidSignature = &SigningError{"INVALID_SIGNATURE", "signature verification failed"}) // Signer handles HMAC request signingtype Signer struct { keyID string secretKey []byte} // NewSigner creates a new HMAC signerfunc NewSigner(keyID string, secretKey []byte) *Signer { return &Signer{ keyID: keyID, secretKey: secretKey, }} // SignRequest signs an HTTP request, returning the signaturefunc (s *Signer) SignRequest(req *http.Request) (string, error) { timestamp := time.Now().UTC().Format(time.RFC3339) // Set timestamp header before signing req.Header.Set(HeaderTimestamp, timestamp) req.Header.Set(HeaderKeyID, s.keyID) // Build canonical request canonical, err := buildCanonicalRequest(req) if err != nil { return "", fmt.Errorf("failed to build canonical request: %w", err) } // Build string to sign stringToSign := buildStringToSign(canonical, timestamp) // Compute HMAC signature := computeHMAC(s.secretKey, stringToSign) // Set signature header req.Header.Set(HeaderSignature, signature) return signature, nil} // Verifier handles HMAC signature verificationtype Verifier struct { keyLookup func(keyID string) ([]byte, error)} // NewVerifier creates a new HMAC verifierfunc NewVerifier(keyLookup func(keyID string) ([]byte, error)) *Verifier { return &Verifier{keyLookup: keyLookup}} // VerifyRequest verifies the HMAC signature on an incoming requestfunc (v *Verifier) VerifyRequest(req *http.Request) error { // 1. Extract required headers signature := req.Header.Get(HeaderSignature) if signature == "" { return ErrMissingSignature } timestampStr := req.Header.Get(HeaderTimestamp) if timestampStr == "" { return ErrMissingTimestamp } keyID := req.Header.Get(HeaderKeyID) if keyID == "" { return &SigningError{"MISSING_KEY_ID", "API key ID header required"} } // 2. Parse and validate timestamp timestamp, err := time.Parse(time.RFC3339, timestampStr) if err != nil { return ErrInvalidTimestamp } now := time.Now().UTC() if timestamp.Before(now.Add(-TimestampTolerance)) || timestamp.After(now.Add(TimestampTolerance)) { return ErrTimestampExpired } // 3. Look up secret key secretKey, err := v.keyLookup(keyID) if err != nil { return &SigningError{"KEY_LOOKUP_FAILED", "unable to find API key"} } // 4. Build canonical request (server-side) canonical, err := buildCanonicalRequest(req) if err != nil { return fmt.Errorf("failed to build canonical request: %w", err) } // 5. Compute expected signature stringToSign := buildStringToSign(canonical, timestampStr) expectedSignature := computeHMAC(secretKey, stringToSign) // 6. Constant-time comparison (critical for security!) if subtle.ConstantTimeCompare( []byte(signature), []byte(expectedSignature), ) != 1 { return ErrInvalidSignature } return nil} // buildCanonicalRequest creates the canonical request stringfunc buildCanonicalRequest(req *http.Request) (string, error) { var buf bytes.Buffer // 1. HTTP Method buf.WriteString(req.Method) buf.WriteByte('\n') // 2. Canonical URI uri := req.URL.Path if uri == "" { uri = "/" } buf.WriteString(uri) buf.WriteByte('\n') // 3. Canonical Query String buf.WriteString(canonicalQueryString(req.URL.Query())) buf.WriteByte('\n') // 4. Canonical Headers signedHeaders, headerString := canonicalHeaders(req) buf.WriteString(headerString) // 5. Signed Headers List buf.WriteString(signedHeaders) buf.WriteByte('\n') // 6. Hashed Payload payloadHash, err := hashPayload(req) if err != nil { return "", err } buf.WriteString(payloadHash) return buf.String(), nil} // canonicalQueryString builds sorted query stringfunc canonicalQueryString(values map[string][]string) string { if len(values) == 0 { return "" } keys := make([]string, 0, len(values)) for k := range values { keys = append(keys, k) } sort.Strings(keys) var pairs []string for _, k := range keys { for _, v := range values[k] { pairs = append(pairs, fmt.Sprintf("%s=%s", k, v)) } } return strings.Join(pairs, "&")} // canonicalHeaders builds the canonical headers stringfunc canonicalHeaders(req *http.Request) (string, string) { // Headers to include in signature includeHeaders := []string{ "content-type", "host", HeaderTimestamp, } headers := make(map[string]string) for _, name := range includeHeaders { lowerName := strings.ToLower(name) value := req.Header.Get(name) if value != "" { headers[lowerName] = strings.TrimSpace(value) } else if lowerName == "host" && req.Host != "" { headers[lowerName] = req.Host } } // Sort header names names := make([]string, 0, len(headers)) for name := range headers { names = append(names, name) } sort.Strings(names) // Build canonical headers string var headerBuf bytes.Buffer for _, name := range names { headerBuf.WriteString(name) headerBuf.WriteByte(':') headerBuf.WriteString(headers[name]) headerBuf.WriteByte('\n') } return strings.Join(names, ";"), headerBuf.String()} // hashPayload computes SHA-256 hash of request bodyfunc hashPayload(req *http.Request) (string, error) { if req.Body == nil { return hashBytes(nil), nil } // Read body (need to restore it after) body, err := io.ReadAll(req.Body) if err != nil { return "", err } req.Body.Close() // Restore body for handlers req.Body = io.NopCloser(bytes.NewReader(body)) return hashBytes(body), nil} // hashBytes computes SHA-256 hex digestfunc hashBytes(data []byte) string { hash := sha256.Sum256(data) return hex.EncodeToString(hash[:])} // buildStringToSign creates the string to signfunc buildStringToSign(canonical string, timestamp string) string { canonicalHash := hashBytes([]byte(canonical)) return fmt.Sprintf("%s\n%s\n%s", Algorithm, timestamp, canonicalHash)} // computeHMAC computes HMAC-SHA256func computeHMAC(key []byte, message string) string { h := hmac.New(sha256.New, key) h.Write([]byte(message)) return hex.EncodeToString(h.Sum(nil))}Never use == or === to compare signatures! Standard string comparison exits early on mismatch, leaking timing information that enables signature forgery. Always use crypto.timingSafeEqual (Node), subtle.ConstantTimeCompare (Go), or hmac.compare_digest (Python).
HMAC authentication binds a signature to request content, but without additional measures, a valid signed request could be captured and replayed indefinitely. Replay attacks allow an attacker to resubmit a legitimate request—even without knowing the secret key—to trigger duplicate actions.
Timestamp-Based Replay Protection:
The most common defense is requiring a timestamp in the signed request and rejecting requests outside a tolerance window:
X-Timestamp: 2024-01-15T10:30:00Z)This simple mechanism dramatically limits the replay window: an attacker has at most 10 minutes to replay a captured request.
| Mechanism | How It Works | Pros | Cons | Best For |
|---|---|---|---|---|
| Timestamp Window | Reject requests outside ±5 min | Simple, stateless | Short replay window exists | Most APIs |
| Nonce (Number Once) | Each request has unique ID, server tracks used nonces | Perfect prevention | Requires storage, scaling issues | Financial transactions |
| Sequence Numbers | Monotonically increasing counter | Simple, low storage | Single client constraint | Server-to-server |
| Timestamp + Nonce Hybrid | Nonce only checked within timestamp window | Bounded storage | More complex | High-security APIs |
| Idempotency Keys | Client-provided key ensures single execution | Client-controlled | Requires client cooperation | Payment APIs |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
import hashlibimport timefrom datetime import datetime, timedelta, timezonefrom typing import Optionalimport redis class ReplayProtection: """ Hybrid replay protection using timestamp windows and nonce tracking. Nonces are only stored for the duration of the timestamp window, limiting storage requirements while providing perfect replay prevention. """ def __init__( self, redis_client: redis.Redis, timestamp_tolerance_seconds: int = 300, # ±5 minutes ): self.redis = redis_client self.tolerance = timestamp_tolerance_seconds # Store nonces for 2x tolerance to handle edge cases self.nonce_ttl = timestamp_tolerance_seconds * 2 def validate_request( self, timestamp_str: str, nonce: str, key_id: str, ) -> tuple[bool, Optional[str]]: """ Validate request against replay attacks. Args: timestamp_str: ISO8601 timestamp from request nonce: Unique request identifier from request key_id: API key identifier for namespacing Returns: Tuple of (is_valid, error_message) """ # 1. Parse timestamp try: timestamp = datetime.fromisoformat( timestamp_str.replace('Z', '+00:00') ) except ValueError: return False, "invalid timestamp format" # 2. Check timestamp is within window now = datetime.now(timezone.utc) min_time = now - timedelta(seconds=self.tolerance) max_time = now + timedelta(seconds=self.tolerance) if timestamp < min_time: return False, "timestamp too old" if timestamp > max_time: return False, "timestamp too far in future" # 3. Check nonce hasn't been used nonce_key = self._nonce_key(key_id, nonce) # SETNX returns 1 if key was set (nonce is new) # Returns 0 if key already exists (nonce was used) is_new = self.redis.setnx(nonce_key, "1") if not is_new: return False, "nonce already used" # Set TTL on nonce key self.redis.expire(nonce_key, self.nonce_ttl) return True, None def _nonce_key(self, key_id: str, nonce: str) -> str: """Generate Redis key for nonce tracking.""" # Hash to prevent key-length issues hash_input = f"{key_id}:{nonce}" hash_value = hashlib.sha256(hash_input.encode()).hexdigest()[:16] return f"nonce:{hash_value}" def cleanup_expired_nonces(self) -> int: """ Manual cleanup of expired nonces (Redis TTL handles this, but can be called for immediate cleanup) """ # Redis automatically cleans up keys with TTL # This method exists for explicit control if needed pattern = "nonce:*" count = 0 for key in self.redis.scan_iter(match=pattern, count=100): if self.redis.ttl(key) <= 0: self.redis.delete(key) count += 1 return count class IdempotencyKeyManager: """ Implements idempotency keys for ensuring exactly-once execution. Commonly used in payment APIs (Stripe-style). """ def __init__( self, redis_client: redis.Redis, key_ttl_hours: int = 24, ): self.redis = redis_client self.key_ttl = key_ttl_hours * 3600 def check_and_store( self, idempotency_key: str, api_key_id: str, request_hash: str, ) -> tuple[bool, Optional[dict]]: """ Check if request with this idempotency key was already processed. Args: idempotency_key: Client-provided idempotency key api_key_id: API key for namespacing request_hash: Hash of request body to detect misuse Returns: Tuple of (is_new, cached_response) - (True, None) if this is a new request - (False, cached_response) if request was already processed """ key = self._cache_key(api_key_id, idempotency_key) existing = self.redis.hgetall(key) if existing: stored_hash = existing.get(b'request_hash', b'').decode() # Verify request body matches if stored_hash != request_hash: raise ValueError( "Idempotency key reused with different request body" ) # Return cached response import json cached_response = json.loads( existing.get(b'response', b'{}').decode() ) return False, cached_response # New request - store placeholder self.redis.hset(key, mapping={ 'request_hash': request_hash, 'status': 'processing', 'started_at': datetime.now(timezone.utc).isoformat(), }) self.redis.expire(key, self.key_ttl) return True, None def store_response( self, idempotency_key: str, api_key_id: str, response: dict, ) -> None: """Store successful response for future lookups.""" import json key = self._cache_key(api_key_id, idempotency_key) self.redis.hset(key, mapping={ 'status': 'complete', 'response': json.dumps(response), 'completed_at': datetime.now(timezone.utc).isoformat(), }) def _cache_key(self, api_key_id: str, idempotency_key: str) -> str: """Generate Redis key for idempotency tracking.""" return f"idempotency:{api_key_id}:{idempotency_key}"Timestamp validation requires reasonably synchronized clocks. In cloud environments, NTP typically keeps clocks within milliseconds. For edge cases, consider accepting a wider window (±10 minutes) and relying more heavily on nonces. Include server time in error responses to help developers debug clock skew issues.
Understanding how industry leaders implement HMAC authentication provides valuable patterns and helps when integrating with these APIs.
AWS Signature Version 4:
AWS SigV4 is the most comprehensive HMAC authentication scheme in wide use. It adds several layers:
This design means:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
import hashlibimport hmacfrom datetime import datetime, timezone def derive_aws_signing_key( secret_key: str, date_stamp: str, region: str, service: str,) -> bytes: """ Derive the AWS SigV4 signing key. This multi-step derivation means: - Different days get different keys - Different regions get different keys - Different services get different keys Even if a signing key is compromised, it's only valid for one date/region/service combination. """ def sign(key: bytes, msg: str) -> bytes: return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() # Key derivation chain k_date = sign(('AWS4' + secret_key).encode('utf-8'), date_stamp) k_region = sign(k_date, region) k_service = sign(k_region, service) k_signing = sign(k_service, 'aws4_request') return k_signing def create_aws_authorization_header( access_key: str, secret_key: str, method: str, canonical_uri: str, canonical_querystring: str, canonical_headers: str, signed_headers: str, payload_hash: str, region: str = 'us-east-1', service: str = 's3',) -> str: """ Create a complete AWS SigV4 Authorization header. This demonstrates the full AWS signing process. In production, use boto3 or official SDKs. """ # Current time t = datetime.now(timezone.utc) amz_date = t.strftime('%Y%m%dT%H%M%SZ') date_stamp = t.strftime('%Y%m%d') # Credential scope credential_scope = f"{date_stamp}/{region}/{service}/aws4_request" # Canonical request canonical_request = '\n'.join([ method, canonical_uri, canonical_querystring, canonical_headers, signed_headers, payload_hash, ]) # String to sign string_to_sign = '\n'.join([ 'AWS4-HMAC-SHA256', # Algorithm amz_date, # Request timestamp credential_scope, # Scope hashlib.sha256(canonical_request.encode('utf-8')).hexdigest(), ]) # Derive signing key signing_key = derive_aws_signing_key( secret_key, date_stamp, region, service ) # Compute signature signature = hmac.new( signing_key, string_to_sign.encode('utf-8'), hashlib.sha256 ).hexdigest() # Build authorization header authorization = ( f"AWS4-HMAC-SHA256 " f"Credential={access_key}/{credential_scope}, " f"SignedHeaders={signed_headers}, " f"Signature={signature}" ) return authorization # Stripe-style HMAC for webhook verificationdef verify_stripe_webhook_signature( payload: bytes, signature_header: str, webhook_secret: str, tolerance_seconds: int = 300,) -> bool: """ Verify Stripe webhook signature. Stripe's format: t=timestamp,v1=signature The signed message is: timestamp + '.' + payload """ # Parse signature header parts = dict(p.split('=') for p in signature_header.split(',')) timestamp = int(parts.get('t', 0)) signature = parts.get('v1', '') # Check timestamp now = int(datetime.now(timezone.utc).timestamp()) if abs(now - timestamp) > tolerance_seconds: return False # Compute expected signature signed_payload = f"{timestamp}.{payload.decode('utf-8')}" expected = hmac.new( webhook_secret.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Constant-time comparison return hmac.compare_digest(signature, expected)HMAC authentication implementations frequently suffer from subtle bugs that undermine security. Here are the most common issues and how to avoid them:
== leaks signature information through response timing. An attacker can determine correct signature bytes one position at a time. Mitigation: Always use constant-time comparison functions.\n vs \r\n differences between platforms. Mitigation: Explicitly use \n in specifications, never os.linesep.+ vs %20, uppercase vs lowercase hex. Mitigation: Document exact encoding rules, use RFC 3986 percent-encoding.1.0 vs 1 distinctions in JSON serialization. Mitigation: Canonicalize JSON or sign raw bytes, not parsed/re-serialized JSON.Always test HMAC implementations with: (1) Known test vectors from RFCs, (2) Edge cases like empty body, Unicode characters, and special URL characters, (3) Replay attack simulation, (4) Tampered request detection. Publish your test vectors so SDK developers can verify compatibility.
Debugging HMAC Failures:
When signatures don't match, debugging can be frustrating. Implement these debugging aids:
HMAC verification adds computational overhead to every request. At scale, this overhead matters. Understanding the performance characteristics helps you design efficient systems.
Computation Costs:
| Operation | Typical Time | Notes |
|---|---|---|
| SHA-256 hash (1KB) | ~5 μs | Scales with payload size |
| HMAC-SHA256 | ~10 μs | Double hash internally |
| Canonical request build | 10-50 μs | String manipulation overhead |
| Secret key lookup | 50 μs - 5 ms | Database/cache access |
| Total verification | 100 μs - 10 ms | Dominated by key lookup |
At 10,000 requests/second, verification overhead consumes 100ms - 100s of CPU time per second. This is significant but manageable with proper architecture.
Modern CDNs (Cloudflare Workers, AWS Lambda@Edge) can verify HMAC signatures at the edge. This rejects invalid requests before they consume origin server resources, provides geographic distribution of verification compute, and adds a layer of defense before application code runs.
HMAC authentication provides cryptographic proof of request authenticity and integrity. Let's consolidate the essential concepts:
What's Next:
HMAC authenticates requests but requires shared secrets—both client and server know the key. The next page explores Request Signing with asymmetric cryptography, where only the client knows the private key, enabling verification without shared secrets and supporting non-repudiation for audit trails.
You now understand HMAC authentication from cryptographic foundations through production implementation. You can design canonical request formats, implement signing and verification, protect against replay attacks, and optimize for high-throughput scenarios. Next, we'll explore asymmetric request signing for scenarios requiring non-repudiation.