Loading learning content...
HMAC authentication requires sharing a secret between client and server. While effective, this creates challenges: if the server is compromised, all client secrets are exposed. If a dispute arises, the server could claim it never received a particular request—since both parties know the secret, either could have created the signature.
Asymmetric request signing solves these problems by separating signing (private key, held only by client) from verification (public key, shared with server). This architecture enables:
From banking APIs requiring audit trails to blockchain transaction signing, asymmetric request signing is the gold standard for high-security API interactions.
By the end of this page, you will understand the cryptographic primitives underpinning request signing (RSA, ECDSA, EdDSA), how to construct and verify signatures, key management considerations for asymmetric systems, industry standards (HTTP Signatures, JWT signing), and practical implementation patterns for production APIs.
Asymmetric (public-key) cryptography uses mathematically related key pairs: a private key (kept secret) and a public key (freely distributed). The fundamental property is that messages signed with the private key can be verified by anyone with the public key, but the private key cannot be derived from the public key.
How Digital Signatures Work:
Signing (client with private key):
Verification (server with public key):
This process guarantees:
| Algorithm | Key Size | Signature Size | Performance | Security Level | Recommendation |
|---|---|---|---|---|---|
| RSA-2048 | 2048 bits | 256 bytes | Slow sign, fast verify | 112 bits | Legacy compatibility only |
| RSA-4096 | 4096 bits | 512 bytes | Very slow sign | 128+ bits | Use if RSA required |
| ECDSA P-256 | 256 bits | 64 bytes | Fast | 128 bits | Good general choice |
| ECDSA P-384 | 384 bits | 96 bytes | Medium | 192 bits | High-security needs |
| Ed25519 | 256 bits | 64 bytes | Very fast | 128 bits | Recommended default |
| Ed448 | 448 bits | 114 bytes | Fast | 224 bits | Post-quantum awareness |
Why Ed25519 is the Modern Default:
Ed25519 (Edwards-curve Digital Signature Algorithm using Curve25519) has become the preferred choice for new implementations:
Major platforms have adopted Ed25519: SSH (since 2014), TLS 1.3, DNSSEC, and cryptocurrency systems like Bitcoin (via related curve secp256k1) and Solana.
ECDSA requires a random nonce for each signature. If the same nonce is used twice, or if the nonce is predictable, the private key can be recovered. This vulnerability has caused real-world key compromises (PlayStation 3, some Bitcoin wallets). Ed25519 is deterministic and immune to this class of bugs.
Let's implement a complete request signing system using Ed25519. The implementation covers key generation, message construction, signing, and verification.
The Complete Signing Flow:
Key Setup (one-time):
Request Signing (per request):
Verification (server-side):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
import base64import hashlibimport jsonfrom datetime import datetime, timezonefrom typing import Dict, Optional, Tuplefrom dataclasses import dataclass # Using cryptography library for Ed25519from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey)from cryptography.hazmat.primitives import serializationfrom cryptography.exceptions import InvalidSignature @dataclassclass SignedRequest: """Represents a request with signature metadata.""" method: str path: str query_params: Dict[str, str] headers: Dict[str, str] body: Optional[bytes] signature: str key_id: str timestamp: str algorithm: str = "ed25519" class Ed25519KeyManager: """ Manages Ed25519 key pairs for request signing. In production, integrate with HSM or key management service. """ @staticmethod def generate_key_pair() -> Tuple[bytes, bytes]: """ Generate a new Ed25519 key pair. Returns: Tuple of (private_key_pem, public_key_pem) """ private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) public_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) return private_pem, public_pem @staticmethod def load_private_key(pem_data: bytes) -> Ed25519PrivateKey: """Load private key from PEM format.""" return serialization.load_pem_private_key(pem_data, password=None) @staticmethod def load_public_key(pem_data: bytes) -> Ed25519PublicKey: """Load public key from PEM format.""" return serialization.load_pem_public_key(pem_data) class RequestSigner: """ Signs HTTP requests using Ed25519 digital signatures. """ def __init__(self, key_id: str, private_key: Ed25519PrivateKey): self.key_id = key_id self.private_key = private_key def sign_request( self, method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: Optional[bytes] = None, ) -> SignedRequest: """ Sign an HTTP request. Returns a SignedRequest with signature and metadata. """ timestamp = datetime.now(timezone.utc).isoformat() # Build canonical message canonical = self._build_canonical_message( method=method, path=path, query_params=query_params, headers=headers, body=body, timestamp=timestamp, ) # Sign the canonical message signature_bytes = self.private_key.sign(canonical.encode('utf-8')) signature = base64.b64encode(signature_bytes).decode('ascii') return SignedRequest( method=method, path=path, query_params=query_params, headers=headers, body=body, signature=signature, key_id=self.key_id, timestamp=timestamp, algorithm="ed25519", ) def _build_canonical_message( self, method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: Optional[bytes], timestamp: str, ) -> str: """ Build canonical message for signing. Format: ed25519 {timestamp} {method} {path} {query_string} {headers_hash} {body_hash} """ # Sort and format query params sorted_params = sorted(query_params.items()) query_string = '&'.join(f"{k}={v}" for k, v in sorted_params) # Hash significant headers significant_headers = ['content-type', 'host'] headers_to_sign = { k.lower(): v for k, v in headers.items() if k.lower() in significant_headers } headers_json = json.dumps(headers_to_sign, sort_keys=True) headers_hash = hashlib.sha256(headers_json.encode()).hexdigest() # Hash body body_hash = hashlib.sha256(body or b'').hexdigest() # Assemble canonical message parts = [ "ed25519", # Algorithm identifier timestamp, # Request timestamp method.upper(), # HTTP method path, # Request path query_string, # Query parameters headers_hash, # Hashed headers body_hash, # Hashed body ] return '\n'.join(parts) class RequestVerifier: """ Verifies Ed25519-signed HTTP requests. """ def __init__(self, public_key_lookup): """ Args: public_key_lookup: Callable[[key_id]] -> Ed25519PublicKey """ self.public_key_lookup = public_key_lookup def verify_request(self, signed_request: SignedRequest) -> bool: """ Verify the signature on a signed request. Args: signed_request: The request with signature metadata Returns: True if signature is valid, raises exception otherwise """ # Look up public key public_key = self.public_key_lookup(signed_request.key_id) if not public_key: raise ValueError(f"Unknown key ID: {signed_request.key_id}") # Rebuild canonical message (server-side) canonical = self._build_canonical_message( method=signed_request.method, path=signed_request.path, query_params=signed_request.query_params, headers=signed_request.headers, body=signed_request.body, timestamp=signed_request.timestamp, ) # Decode signature try: signature_bytes = base64.b64decode(signed_request.signature) except Exception: raise ValueError("Invalid signature encoding") # Verify signature try: public_key.verify(signature_bytes, canonical.encode('utf-8')) return True except InvalidSignature: raise ValueError("Signature verification failed") def _build_canonical_message( self, method: str, path: str, query_params: Dict[str, str], headers: Dict[str, str], body: Optional[bytes], timestamp: str, ) -> str: """Build canonical message (same as signer).""" sorted_params = sorted(query_params.items()) query_string = '&'.join(f"{k}={v}" for k, v in sorted_params) significant_headers = ['content-type', 'host'] headers_to_sign = { k.lower(): v for k, v in headers.items() if k.lower() in significant_headers } headers_json = json.dumps(headers_to_sign, sort_keys=True) headers_hash = hashlib.sha256(headers_json.encode()).hexdigest() body_hash = hashlib.sha256(body or b'').hexdigest() parts = [ "ed25519", timestamp, method.upper(), path, query_string, headers_hash, body_hash, ] return '\n'.join(parts) # Example usageif __name__ == "__main__": # Generate key pair private_pem, public_pem = Ed25519KeyManager.generate_key_pair() print("Generated key pair") # Set up signer (client-side) private_key = Ed25519KeyManager.load_private_key(private_pem) signer = RequestSigner(key_id="client_001", private_key=private_key) # Sign a request signed = signer.sign_request( method="POST", path="/v1/transfers", query_params={"expand": "source"}, headers={ "Host": "api.example.com", "Content-Type": "application/json", }, body=b'{"amount": 10000, "currency": "usd"}', ) print(f"Signature: {signed.signature}") print(f"Timestamp: {signed.timestamp}") print(f"Key ID: {signed.key_id}") # Set up verifier (server-side) public_key = Ed25519KeyManager.load_public_key(public_pem) def lookup_key(key_id: str): if key_id == "client_001": return public_key return None verifier = RequestVerifier(lookup_key) # Verify the signature try: result = verifier.verify_request(signed) print(f"Verification result: {result}") except ValueError as e: print(f"Verification failed: {e}")For high-security applications, private keys should never leave a Hardware Security Module (HSM). AWS CloudHSM, Google Cloud HSM, and Azure Dedicated HSM all support Ed25519 signing. The private key is generated inside the HSM and signing operations happen within the secure boundary.
Rather than inventing custom signing schemes, many APIs adopt the HTTP Signatures specification (RFC 9421, formerly draft-cavage-http-signatures). This standard defines how to:
The HTTP Signature Header Format:
Signature: keyId="client_001",
algorithm="ed25519",
headers="(request-target) host date content-digest",
signature="base64-signature-here"
Components:
keyId: Identifier to look up the verification keyalgorithm: Signature algorithm (e.g., ed25519, rsa-sha256)headers: Space-separated list of headers included in signaturesignature: Base64-encoded signature valueThe (request-target) pseudo-header combines method and path, ensuring the signature covers the request target.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
import base64import hashlibimport refrom datetime import datetime, timezonefrom typing import Dict, List, Optionalfrom dataclasses import dataclass from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey) @dataclassclass HTTPSignatureParams: """Parameters for HTTP Signature verification.""" key_id: str algorithm: str headers: List[str] signature: bytes created: Optional[int] = None expires: Optional[int] = None class HTTPSignatureSigner: """ Implements RFC 9421 HTTP Message Signatures. This is the standard way to sign HTTP requests with interoperability across implementations. """ SIGNED_HEADERS = [ "(request-target)", "host", "date", "content-digest", "content-type", ] def __init__( self, key_id: str, private_key: Ed25519PrivateKey, algorithm: str = "ed25519", ): self.key_id = key_id self.private_key = private_key self.algorithm = algorithm def sign_request( self, method: str, path: str, headers: Dict[str, str], body: Optional[bytes] = None, ) -> Dict[str, str]: """ Sign a request and return headers to add. Args: method: HTTP method (GET, POST, etc.) path: Request path including query string headers: Existing request headers body: Request body bytes Returns: Dict of headers to add to request """ # Ensure required headers exist if "date" not in {k.lower() for k in headers}: headers["Date"] = datetime.now(timezone.utc).strftime( "%a, %d %b %Y %H:%M:%S GMT" ) # Add Content-Digest for requests with body if body: digest = hashlib.sha256(body).digest() headers["Content-Digest"] = f"sha-256=:{base64.b64encode(digest).decode()}:" # Build signing string signing_string = self._build_signing_string( method=method, path=path, headers=headers, ) # Sign signature = self.private_key.sign(signing_string.encode('utf-8')) signature_b64 = base64.b64encode(signature).decode('ascii') # Format Signature header headers_str = ' '.join(self.SIGNED_HEADERS) signature_header = ( f'keyId="{self.key_id}", ' f'algorithm="{self.algorithm}", ' f'headers="{headers_str}", ' f'signature="{signature_b64}"' ) return { "Signature": signature_header, "Date": headers.get("Date", headers.get("date")), "Content-Digest": headers.get("Content-Digest", ""), } def _build_signing_string( self, method: str, path: str, headers: Dict[str, str], ) -> str: """ Build the signing string per RFC 9421. Each line is: lowercase-header-name: value Special pseudo-headers like (request-target) are computed. """ lines = [] # Normalize headers to lowercase keys normalized_headers = {k.lower(): v for k, v in headers.items()} for header in self.SIGNED_HEADERS: if header == "(request-target)": # Pseudo-header: method + space + path lines.append(f"(request-target): {method.lower()} {path}") elif header == "(created)": # Signature creation timestamp created = int(datetime.now(timezone.utc).timestamp()) lines.append(f"(created): {created}") elif header == "(expires)": # Signature expiration timestamp pass # Optional, skip if not used else: # Regular header value = normalized_headers.get(header, "") if value: lines.append(f"{header}: {value}") return '\n'.join(lines) class HTTPSignatureVerifier: """ Verifies HTTP Message Signatures per RFC 9421. """ # Signature validity window (5 minutes) MAX_AGE_SECONDS = 300 def __init__(self, public_key_lookup): """ Args: public_key_lookup: Callable[[key_id]] -> Ed25519PublicKey """ self.public_key_lookup = public_key_lookup def verify_request( self, method: str, path: str, headers: Dict[str, str], body: Optional[bytes] = None, ) -> str: """ Verify request signature. Returns: The key_id if verification succeeds Raises: ValueError: If verification fails """ # Parse Signature header signature_header = headers.get("Signature") or headers.get("signature") if not signature_header: raise ValueError("Missing Signature header") params = self._parse_signature_header(signature_header) # Look up public key public_key = self.public_key_lookup(params.key_id) if not public_key: raise ValueError(f"Unknown key ID: {params.key_id}") # Verify content digest if present content_digest = headers.get("Content-Digest") or headers.get("content-digest") if body and content_digest: self._verify_content_digest(body, content_digest) # Validate timestamp date_header = headers.get("Date") or headers.get("date") if date_header: self._validate_timestamp(date_header) # Rebuild signing string signing_string = self._build_signing_string( method=method, path=path, headers=headers, signed_headers=params.headers, ) # Verify signature try: public_key.verify(params.signature, signing_string.encode('utf-8')) except Exception: raise ValueError("Signature verification failed") return params.key_id def _parse_signature_header(self, header: str) -> HTTPSignatureParams: """Parse the Signature header value.""" # Parse key="value" pairs pattern = r'(\w+)="([^"]*)"' matches = dict(re.findall(pattern, header)) return HTTPSignatureParams( key_id=matches.get("keyId", ""), algorithm=matches.get("algorithm", ""), headers=matches.get("headers", "").split(), signature=base64.b64decode(matches.get("signature", "")), created=int(matches.get("created", 0)) if "created" in matches else None, expires=int(matches.get("expires", 0)) if "expires" in matches else None, ) def _build_signing_string( self, method: str, path: str, headers: Dict[str, str], signed_headers: List[str], ) -> str: """Rebuild signing string from request.""" lines = [] normalized_headers = {k.lower(): v for k, v in headers.items()} for header in signed_headers: if header == "(request-target)": lines.append(f"(request-target): {method.lower()} {path}") else: value = normalized_headers.get(header, "") if value: lines.append(f"{header}: {value}") return '\n'.join(lines) def _verify_content_digest(self, body: bytes, digest_header: str) -> None: """Verify Content-Digest header matches body.""" # Parse digest header (format: sha-256=:base64:) match = re.match(r'sha-256=:([^:]+):', digest_header) if not match: raise ValueError("Invalid Content-Digest format") expected_digest = base64.b64decode(match.group(1)) actual_digest = hashlib.sha256(body).digest() if expected_digest != actual_digest: raise ValueError("Content-Digest mismatch") def _validate_timestamp(self, date_header: str) -> None: """Validate request timestamp is within acceptable window.""" from email.utils import parsedate_to_datetime try: request_time = parsedate_to_datetime(date_header) except Exception: raise ValueError("Invalid Date header format") now = datetime.now(timezone.utc) age = abs((now - request_time).total_seconds()) if age > self.MAX_AGE_SECONDS: raise ValueError("Request timestamp outside valid window")The Content-Digest header (RFC 9530) provides integrity verification for the message body separately from the signature. This allows intermediaries to verify body integrity without needing signing keys, and supports scenarios where only specific headers are signed.
Asymmetric key management differs significantly from symmetric (shared secret) systems. The fundamental difference is that public keys can be freely distributed, but private keys require extreme protection.
Key Lifecycle:
| Storage Method | Security Level | Availability | Cost | Best For |
|---|---|---|---|---|
| Environment Variable | Low | High | Free | Development only |
| Encrypted File | Medium | High | Low | Small deployments |
| Secrets Manager (AWS, GCP) | High | High | Medium | Cloud applications |
| HashiCorp Vault | High | High | Medium | Multi-cloud, on-prem |
| Cloud HSM | Very High | High | High | Financial, regulated |
| On-Premises HSM | Very High | Medium | Very High | Highest security needs |
Public Key Distribution:
Unlike private keys, public keys should be easily accessible:
| Method | Use Case | Considerations |
|---|---|---|
| Direct registration | API clients upload public key to dashboard | Simple, manual |
| JWKS endpoint | Server publishes keys at .well-known/jwks.json | Standard, automatic discovery |
| X.509 certificates | Keys wrapped in certificates with CA signatures | Enterprise, PKI integration |
| Key transparency logs | Public, auditable key registry | High-assurance, detects tampering |
For most API scenarios, direct registration with optional JWKS publication is sufficient.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
from dataclasses import dataclassfrom datetime import datetime, timezonefrom typing import Optional, Listimport hashlibimport base64 @dataclassclass RegisteredPublicKey: """Represents a registered public signing key.""" key_id: str organization_id: str algorithm: str public_key_pem: str public_key_fingerprint: str name: str created_at: datetime expires_at: Optional[datetime] revoked_at: Optional[datetime] = None last_used_at: Optional[datetime] = None allowed_scopes: List[str] = None class PublicKeyRegistry: """ Manages registration and lookup of public signing keys. """ SUPPORTED_ALGORITHMS = ["ed25519", "rsa-sha256", "ecdsa-p256"] def __init__(self, database): self.db = database def register_key( self, organization_id: str, public_key_pem: str, algorithm: str, name: str, scopes: Optional[List[str]] = None, expires_at: Optional[datetime] = None, ) -> RegisteredPublicKey: """ Register a new public key for an organization. Args: organization_id: The organization this key belongs to public_key_pem: PEM-encoded public key algorithm: Signing algorithm (ed25519, rsa-sha256, etc.) name: Human-readable name for the key scopes: Optional list of allowed scopes expires_at: Optional expiration time Returns: The registered key with its assigned ID """ # Validate algorithm if algorithm not in self.SUPPORTED_ALGORITHMS: raise ValueError(f"Unsupported algorithm: {algorithm}") # Validate public key format self._validate_public_key(public_key_pem, algorithm) # Generate key ID and fingerprint key_id = self._generate_key_id() fingerprint = self._compute_fingerprint(public_key_pem) # Check for duplicate fingerprint existing = self.db.find_by_fingerprint(fingerprint) if existing: raise ValueError("This public key is already registered") # Create record key = RegisteredPublicKey( key_id=key_id, organization_id=organization_id, algorithm=algorithm, public_key_pem=public_key_pem, public_key_fingerprint=fingerprint, name=name, created_at=datetime.now(timezone.utc), expires_at=expires_at, allowed_scopes=scopes or [], ) self.db.save(key) return key def get_key(self, key_id: str) -> Optional[RegisteredPublicKey]: """ Look up a public key by ID. Returns None if key not found or is revoked/expired. """ key = self.db.get_by_id(key_id) if not key: return None # Check revocation if key.revoked_at: return None # Check expiration if key.expires_at and key.expires_at < datetime.now(timezone.utc): return None # Update last used self.db.update_last_used(key_id) return key def revoke_key( self, key_id: str, revoked_by: str, reason: str, ) -> None: """ Revoke a public key immediately. """ self.db.revoke( key_id=key_id, revoked_at=datetime.now(timezone.utc), revoked_by=revoked_by, reason=reason, ) def list_keys( self, organization_id: str, include_revoked: bool = False, ) -> List[RegisteredPublicKey]: """ List all keys for an organization. """ return self.db.list_by_org( organization_id=organization_id, include_revoked=include_revoked, ) def _generate_key_id(self) -> str: """Generate a unique key ID.""" import secrets return f"pk_{secrets.token_urlsafe(16)}" def _compute_fingerprint(self, public_key_pem: str) -> str: """ Compute SHA-256 fingerprint of public key. This is used for detecting duplicate registrations. """ # Remove PEM headers and decode lines = public_key_pem.strip().split('\n') key_data = ''.join(l for l in lines if not l.startswith('-----')) key_bytes = base64.b64decode(key_data) # SHA-256 fingerprint return hashlib.sha256(key_bytes).hexdigest() def _validate_public_key(self, pem: str, algorithm: str) -> None: """Validate that PEM contains a valid public key.""" from cryptography.hazmat.primitives import serialization try: serialization.load_pem_public_key(pem.encode()) except Exception as e: raise ValueError(f"Invalid public key: {e}")Unlike symmetric keys where rotation requires coordinating secret distribution, asymmetric key rotation simply means registering a new public key. The old key can remain valid for a transition period while clients update to the new private key. This makes rotation significantly less disruptive.
Non-repudiation is the guarantee that a signer cannot deny having signed a message. With asymmetric signatures, this becomes technically enforceable: if a message has a valid signature and the private key was properly protected, the signature could only have come from the private key holder.
Legal and Compliance Value:
In many jurisdictions, properly implemented digital signatures have legal standing equivalent to handwritten signatures. This is critical for:
Implementing Audit-Grade Logging:
@dataclass
class SignedRequestAuditRecord:
"""Immutable audit record for signed requests."""
# Request identification
request_id: str
timestamp: datetime
# The signed request (preserved exactly)
method: str
path: str
headers: Dict[str, str] # Includes Signature header
body_hash: str # SHA-256 of body
body: Optional[bytes] # Full body if retention required
# Signature verification result
verification_result: str # 'valid', 'invalid', 'unknown_key'
key_id: str
key_organization: str
# Server-side attestation
server_timestamp: datetime
server_node_id: str
# Optional: Third-party timestamp
rfc3161_timestamp: Optional[bytes] # TSA response
These records should be stored in append-only, tamper-evident storage (database with cryptographic audit log, blockchain, or write-once storage).
For high-value transactions, consider using an RFC 3161 Time-Stamp Authority (TSA) to cryptographically attest to when a signature was created. Services like DigiCert, GlobalSign, and open-source OpenTSA provide this capability. The TSA's signature over your signature's hash proves the signature existed at a specific time.
JWTs (JSON Web Tokens) use the same cryptographic primitives for signing but serve a different purpose. Understanding when to use each approach is crucial for API design.
Request Signing vs. JWTs:
| Scenario | Best Approach | Reasoning |
|---|---|---|
| User authentication for web app | JWT | Session-based, many requests with same identity |
| Server-to-server API calls | Request Signing | Each request should be independently verifiable |
| Financial transaction submission | Request Signing | Non-repudiation, request body integrity critical |
| OAuth2 access delegation | JWT | Standard for delegated authorization |
| Webhook verification | Request Signing | Receiving server needs to verify sender |
| Mobile app API access | JWT (+ optional signing) | Balance security with performance |
| Regulatory/audit-sensitive operations | Request Signing | Full non-repudiation trail required |
For maximum security, use both: JWT for authenticating identity/permissions, and request signing for integrity/non-repudiation. The JWT identifies who is making the request; the request signature proves what they requested and that it hasn't been modified.
Asymmetric operations are computationally more expensive than symmetric (HMAC) operations, but modern algorithms and hardware make them practical for high-throughput APIs.
Performance Characteristics:
| Algorithm | Sign Time | Verify Time | Relative to HMAC |
|---|---|---|---|
| HMAC-SHA256 | ~5 μs | ~5 μs | 1x (baseline) |
| Ed25519 | ~50 μs | ~100 μs | ~10-20x slower |
| ECDSA P-256 | ~150 μs | ~250 μs | ~30-50x slower |
| RSA-2048 | ~1000 μs | ~50 μs | Sign slow, verify fast |
| RSA-4096 | ~5000 μs | ~200 μs | Very slow signing |
Key Observations:
For extremely high-throughput APIs (millions of requests/second), consider hybrid approaches: use HMAC for routine requests and require full asymmetric signatures only for sensitive operations. The authentication tier can be tiered based on operation risk level.
Request signing with asymmetric cryptography provides the strongest form of API authentication, enabling non-repudiation and eliminating shared secret risks. Let's consolidate the key concepts:
What's Next:
Authentication proves identity, but doesn't prevent abuse. The next page explores Rate Limiting for Security, covering how rate limits protect APIs from brute-force attacks, credential stuffing, and denial-of-service, going beyond simple request throttling to intelligent abuse detection.
You now understand asymmetric request signing from cryptographic foundations through production implementation. You can select appropriate algorithms, implement signing and verification, manage asymmetric keys securely, and build audit trails for compliance. Next, we'll explore rate limiting as a security mechanism.