Enhancing Twilio HTTP Request Authentication Using PKCV and Enterprise/Security Editions

March 25, 2025
Written by
Matt Coser
Twilion
Reviewed by
Paul Kamp
Twilion

Public Key Client Validation (PKCV) is a security mechanism that leverages asymmetric cryptography to authenticate HTTP clients and validate their requests. As cyber threats evolve, Public Key Client Validation provides a robust alternative to using Basic Authentication alone, offering enhanced security for your Twilio app. At Twilio, customers using our Enterprise or Security Editions can leverage PKCV. In this article we will review what PKCV is made of, how it works, and how it actually happens.

Prerequisites

Before starting, be sure to have the following ready:

Key terms

Implementing PKCV is a multi-step process, using several different technologies. Your exact implementation will depend on your organization’s needs. This guide aims to break down each concept into defined pieces that can be used to build an implementation of Public Key Client Validation from scratch.

But before writing any code, it is important to agree on some key terms. In the next few sections, we’ll define some terms you’ll want to be familiar with before implementing PKCV.

Asymmetric Cryptography

Asymmetric encryption, also known as public-key cryptography, is a type of encryption that uses a pair of keys for secure communication - a public key and a private key. The Private Key is owned by the issuer and is used to compute the signature. The Public Key is shared with Twilio, which we use to verify that the token was indeed signed with the corresponding private key, thus affirming the token's authenticity and integrity.

RSA

RSA is a cryptographic algorithm, originally introduced in 1977, that uses a pair of keys to encrypt and decrypt data. RSA stands for Rivest-Shamir-Adleman, after its inventors Ron Rivest, Adi Shamir, and Leonard Adleman.

To digitally sign a request with RSA, the sender encrypts a hash of the request with their private key. The recipient of the message can use the sender's public key to decrypt this hash. If it matches the hash of the received request, it confirms the request’s integrity and verifies the sender's identity.

Canonical Request

A Canonical Request is a standardized representation of an HTTP request. This representation ensures consistent formatting of the request for cryptographic signing and validation purposes. A Canonical Request will look something like this:

Canonical HTTP Method + '\n' +
  Canonical URI + '\n' +
  Canonical Query String + '\n' +
  Canonical Headers + '\n' +
  Signed Headers + '\n' +
  HexEncode(Hash(Request Body))

Auth Token

By default, all requests to Twilio require Basic Authentication. Your Account SID is the username and your Auth Token is the password.

Like this:

curl -X GET "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls.json" \
-u $TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN

Each Account SID only has one Auth Token. Auth Tokens are not scoped to specific permissions, and can perform all account actions. Using Auth Tokens can be specifically challenging for organizations where shared secrets are not allowed. However, if compromised they can be rotated immediately via Console or the REST API.

Screenshot of API auth token details from Twilio with instructions on secure storage.

API Keys

An Account can have multiple API Key/ Secret pairs, scoped for different users or purposes.

They are used the same way as the Account SID and Auth Token in Basic Authentication.

curl -X POST "https://api.twilio.com/2010-04-01/$TWILIO_ACCOUNT_SID/Calls.json" \
-H "Authorization: Basic {Base64Encoded($TWILIO_API_KEY:$TWILIO_API_SECRET)} \
-F "Status=completed"

In the event an API secret becomes compromised or is no longer in use, it can be revoked to block unauthorized access without impacting other keys or other parts of your application. API Keys give more granular control for applications with more complex security needs.

JWT

From jwt.io:

"JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signed tokens can verify the integrity of the claims contained within it, while encrypted tokens hide those claims from other parties. When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.”

( RFC 7519)

Screenshot of a JWT encoder on the left and decoder on the right showing header, payload, and signature data.

Public Key Client Validation

While Basic Authentication works great for some applications, it can introduce risks that some organizations are unwilling to accept.

Public key validation uses asymmetric encryption, allowing secure communication without the need to share a secret key.

Public key cryptography involves encrypting the request data, which protects sensitive information during transmission. Basic Authentication typically involves sending credentials in a header, which can potentially be intercepted.

A request to Twilio made with PKCV uses a JWT in the Twilio-Client-Validation header, like this:

curl -X POST "https://api.twilio.com/2010-04-01/$TWILIO_ACCOUNT_SID/Calls.json" \
-H "Twilio-Client-Validation: $JWT_TOKEN" \
-F "Status=completed"

We will review how to construct this JWT later, but for now just know it is a hashed version of a request that can be validated by Twilio.

PKCV also requires including timestamps in the signed messages, helping prevent replay attacks. Any tampering after the request is sent would invalidate the signature on the receiving end.

Let’s begin!

Public Key Client Validation is implemented using the following general steps:

  • Create a new API Key/ Secret Pair
  • Generate an RSA Key Pair
  • Submit the RSA Public Key to Twilio
  • Canonicalize and Hash the Request
  • Generate the JWT
  • Attach the JWT to the request header

For demonstration purposes, the first method of achieving these steps will be outlined from scratch in Python, without the use of Twilio libraries.

Then, the Twilio Python Helper Library will be used with a more straightforward implementation.

Logos of Twilio (red circle with four dots) and Python (blue and yellow snake) side by side.

Steps 1-3 only need to be performed once per HTTP client. For example, if you have multiple users, you would create an API Key/ Secret Pair and an RSA Key Pair, and submit the Public key for each user. Then, each user can configure their HTTP client with this information when making requests to Twilio’s APIs.

Diagram showing three HTTP clients with private keys sending HTTP requests to Twilio

Steps 4-6 relate to formatting and sending requests to Twilio, in a way where Twilio can validate the contents of the requests, and authorize the user or application making the request.

The full working code for both halves of the process can be found in my github repo.

Method A - Homemade PKCV with Python

PKCV can be implemented from scratch, without using Twilio or other external libraries. This approach is beneficial for understanding the underlying process and to minimize dependencies in environments where reducing external libraries is required for security or compatibility reasons.

1. Create new API Key/ Secret Pair

Each HTTP client should have its own API Key/ Secret Pair, which can be obtained via Console, or using the API Keys Resource.

api_key_url = "https://iam.twilio.com/v1/Keys"
api_key_data = {
    "FriendlyName": f"pkcv-standard-api-key_{now_date}",
    "AccountSid": ACCOUNT_SID
}
http_auth = HTTPBasicAuth(ADMIN_API_KEY, ADMIN_API_SECRET)
try:
    api_key_response = requests.post(api_key_url, data=api_key_data, auth=http_auth).json()
    new_api_key_sid = api_key_response['sid']
    new_api_key_secret = api_key_response['secret']
    logging.info("API Key/ Secret Pair successfully created...")
except Exception as e:
    logging.error(f"{e}")

Be sure to note the secret for later use as it is only returned once in the initial response, and cannot be obtained later.

2. Generate an RSA Key Pair

A private key is generated with math. A public key is then derived from the private key. For demonstration purposes, the .pem files can be saved in the current working directory.

  • Always remember to adequately protect your key files and secrets in production.
  • Always encrypt your private key with a strong passphrase to prevent unauthorized access if the file is compromised.
  • Avoid storing the key directly in your application code.
  • Practice secure key management to handle key loading, decryption, and signing operations.

With Python, RSA key pairs can be generated with the cryptography package.

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
private_key = rsa.generate_private_key(
    public_exponent=65537, # used in cryptographic algorithm to derive the private key
    key_size=2048, # size of the key in bits - 2048 is secure
    backend=default_backend() # https://cryptography.io/en/3.0/hazmat/backends/#getting-a-backend
)
# The public key is derived from the private key
public_key = private_key.public_key() 
# Load the private pem file as a bytestring and save it to the current working directory
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.BestAvailableEncryption(passphrase)
)
with open(f"{new_api_key_sid}_private.pem", "wb") as private_file:
    private_file.write(private_pem)
    logging.info("RSA Private Key successfully generated and saved...")
# Load the public key as a bytestring, and save it as well
public_pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open(f"{new_api_key_sid}_public.pem", "wb") as public_file:
    public_file.write(public_pem)
    logging.info("RSA Public Key successfully generated and saved...")

Cryptography is not a standard Python library, so an RSA key pair can also be generated using the command line if needed.

openssl genrsa -aes256 -out private_key.pem 2048

When prompted, enter a passphrase to encrypt the private key.

The private key is then used to create the public key:

openssl rsa -pubout -in private_key.pem -out public_key.pem

3. Submit the Public Key to Twilio

RSA Key pairs, when shared with Twilio, are scoped to a specific API Key, enabling granular revocation and renewal.

Twilio’s CredentialPublicKey Resource can be hit with a POST containing the public key, and will return a Credential SID which is later used to ‘link’ the JWT to the associated Private Key.

For the purposes of curiosity (or necessity), the standard http client library can be used instead of the external requests package.

conn = http.client.HTTPSConnection("accounts.twilio.com")
api_key_data = {
    "FriendlyName": f"{new_api_key_sid}-publickey",
    "PublicKey": public_pem
}
encoded_data = urlencode(api_key_data)
headers = {
    'Authorization': f'Basic {base64_basicauth(ADMIN_API_KEY, ADMIN_API_SECRET)}', # A Main Key is required to manage PublicKeys
    'Content-Type': 'application/x-www-form-urlencoded'
}
api_key_url_path = f"/v1/Credentials/PublicKeys"
conn.request("POST", api_key_url_path, encoded_data, headers)
response = conn.getresponse()
response_data = response.read().decode()
if response.status != 201:
    logging.error(f"Error POSTing to {api_key_url_path}")
else:
    response_json = json.loads(response_data)
    new_credential_sid = response_json.get('sid')
    logging.info("Public Key submitted to Twilio Successfully!")
conn.close()

Additionally, Public Keys can also be uploaded and managed via Console.

Form for creating new public key credentials with fields for Friendly Name, Region, and Public Key

Done! We have all the information needed to configure a new HTTP client to make requests using PKCV.

  • Twilio API Key
  • Twilio API Secret
  • RSA Public Key
  • RSA Private Key
  • Credential SID from submitting the RSA Public Key to Twilio

Save this information as environment variables in your app to use in the following steps, and to make future requests. For example, if using virtualenv, add the following to the end of venv/bin/activate, and reactivate the virtual environment.

export TWILIO_API_KEY=SK00000000000000000000000000000000
export TWILIO_API_SECRET=api-secret-string
export TWILIO_CREDENTIAL_SID=CR00000000000000000000000000000000
export PRIVATE_PEM_FILE=SK00000000000000000000000000000000_private.pem
export PUBLIC_PEM_FILE=SK00000000000000000000000000000000_public.pem

4. Canonicalize and Hash the request

A standardized representation of an HTTP request ensures consistent formatting for cryptographic signing and validation purposes. For this demonstration, a Canonical Request should look like this:

GET
/2010-04-01/Accounts/AC00000000000000000000000000000000/Calls.json?Status=completed
authorization:Basic QUMwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDpmb29iYXI=
host:api.twilio.com
authorization;host
B8e20591615abc52293f088c87be6df8e9b7b40c3da573f134c9132add851e2d

In Python, each element of the canonical request can be handled with its own function.

def create_canonical_request(http_method, path, query_string, headers, body):
    print(query_string)
    return '\n'.join([
        canonicalize_http_method(http_method),
        canonicalize_resource_path(path),
        canonicalize_query_string(query_string),
        canonicalize_headers(headers),
        canonicalize_signed_headers(headers),
        hash_request_body(body)
    ])

Let’s break this down, function by function.

Canonicalized HTTP Method

def canonicalize_http_method(method):
    return method.strip().upper()
def canonicalize_resource_path(path):
    normalized_path = urllib.parse.urlparse(path).path
    if not normalized_path:
        return '/'
    return urllib.parse.quote(normalized_path, safe='/-_.!=')
  • urllib.parse.urlparse(path).path parses the URL string, and isolates the path
  • urllib.parse.quote(normalized_path, safe='/-_.!=') isolates and encodes the path, ensuring that special characters are percent-encoded, while allowing specific characters defined in safe to remain unencoded.
  • For Twilio PKCV, you must ensure the following:
    • A space should always be '%20'
    • An asterisk should always be '%2A'
    • A tilde (‘~’) should always be '%7E'
    • An empty string path should always be '/'

Given a URL like https://api.twilio.com/2010-04-01/$TWILIO_ACCOUNT_SID/Calls.json?Status=completed, this function will return /2010-04-01/$TWILIO_ACCOUNT_SID/Calls.

Canonicalized query string

def canonicalize_query_string(query_string):
    query_params = urllib.parse.parse_qsl(query_string, keep_blank_values=True)
    sorted_params = sorted((urllib.parse.quote(k, safe='/-_.!='),
                            urllib.parse.quote(v, safe='/-_.!='))
                           for k, v in query_params)
    return '&'.join(f"{k}={v}" for k, v in sorted_params)
  • urllib.parse.parse_qsl(query_string, keep_blank_values=True) breaks down the query string into a list of key-value pairs, and ensures that parameters with empty values are retained.
  • For each key-value pair (k, v), both k and v are URL-encoded using urllib.parse.quote, with safe='/-_.!=' ensuring all characters are encoded using the same considerations as above.
  • sorted() sorts the list of encoded pairs alphabetically
  • In the return statement, the sorted and encoded key value pairs are reconstructed into a query string with join()

This function would take a query string

?from=4151234567&to=4157654321&message=Test Message

And return:

from=4151234567&message=Test%20Message&to=4157654321

Canonicalized Headers

def canonicalize_headers(headers):
    canonical = []
    for key, value in headers.items():
        clean_key = key.lower().strip()
        clean_value = ' '.join(value.strip().split())
        canonical.append(f"{clean_key}:{clean_value}")
    canonical.sort()
    return '\n'.join(canonical) + '\n'
  • Lower-case and trim each header key with .lower() and .strip()
  • Trim each header value and reduce blank spaces
  • Sort header values that correspond to the same key
  • Combine the formatted key/values - {key}:{values}\n
  • .sort() is used to sort alphabetically if the original list is not needed
  • Each header line is terminated with a \n for a blank line between the canonical-headers and the canonical-hashed-headers in the final canonical request

A Header object like this:

{'Host': 'api.twilio.com', 'Authorization': 'Basic <token>'}

Would be converted to:

authorization:Basic <token>
host:api.twilio.com

Canonicalized hashed headers

Some use cases may only require certain headers to be validated, but in this example all of them are hashed.

def canonicalize_signed_headers(headers):
    signed_headers = [key.lower() for key in headers]
    signed_headers.sort()
    return ';'.join(signed_headers)

The hashed-headers are canonicalized by the following operations:

  • Isolate each header
  • Lowercase and trim
  • Sort alphabetically
  • Join with semicolon

Hashed and Encoded Request Body

def hash_request_body(body):
    if body:
        return hashlib.sha256(body.encode('utf-8')).hexdigest()
    return ''

Hash the request body using SHA-256 and hex-encode the resulting hash. (Classic move.)

If the request body is empty, omit hashing it.

This JSON request body…

{"FriendlyName":"Test Friendly Name"}

…becomes

e897b8a11eb7d7c8e9afa634563c1851bfb0376283cba591032262beaaf50828

5. Generate the JWT

A JSON Web Token (JWT) is a URL-safe string, used to securely transmit information between two parties, consisting of three parts:

  1. Header - specifies the token type and the algorithm used for signing, such as HMAC SHA256 or RSA.
  2. Payload - contains the data or information about the user or session, organized in key-value pairs.
  3. Signature - ensures the token's integrity and authenticity.
def create_jwt(canonical_request, api_key, account_sid, credential_sid, private_key):
    request_hash = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
    jwt_headers = {
        "cty": "twilio-pkrv;v=1", # ContentType = Twilio Public Key Request Validation - Version 1
        "typ": "JWT", # Media Type = JSON Web Token, other values rejected
        "alg": "RS256", # RS256 or PS256 are supported. RS256 = RSASSA-PKCS-v1_5 using SHA-256. PS256 = RSASSA-PSS using SHA-SHA 256,
        "kid": credential_sid # Key ID = Public Key Credential SID associated with the private key used to sign this JWT
    }
    jwt_payload = {
        "hrh": "authorization;host", # semlicolon delimited array of lowercase headers to include in the request hash calculation
        "rqh": request_hash, # Hash of the Canonical Request
        "iss": api_key, # Issuer = APIKey Sid used to match against request credentials
        "exp": int(time.time()) + 300, # Token Expiry Time: token received after exp will be rejected. Max exp - nbf is 300 secs
        "nbf": int(time.time()), # Not Before Time: (Default: 'now')
        "sub": account_sid # Subject = AccountSid
    }
    jwt_token = jwt.encode(jwt_payload, private_key, algorithm="RS256", headers=jwt_headers)
    return jwt_token
  • The request_hash variable is the encoded hash of the entire canonical request constructed in step 4. This is included in the JWT so Twilio can compare its computed request hash for validation.
  • The final jwt_token is generated and signed with the Private Key associated with the Public Key and Credential SID from Steps 1-3.
  • The resulting JWT looks like this:

eyJjdHkiOiJ0d2lsaW8tcGtydjt2PTEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkNSMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAifQ.eyJpc3MiOiJTSzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic3ViIjoiQUMwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImV4cCI6MTQ3MTgyNzM1NCwiaHJoIjoiYXV0aG9yaXphdGlvbjtob3N0IiwicnFoIjoiMjQ1ZWVjZTFlNjM4ZDliMDA4MWNhMDYyMTE4M2NkNDE3ZmM5N2ExODE4YmQ4MjJhYTI2Njk3ZjlhYTcwYzc5MiJ9.a8Z-NXPEf8FrfEpxYBF8kIdn_1VAoa4H6t_X_CmtT7YksKkLMsQl6X00Hx0zEItgu64Z-qeaANxmwme6Y7nRRVz2AV8ZPTv5sWPhXOHVevyEDf2QfPpteDd0gpoPA4KjaklJtnNR8iSAd68DBaUvVE6bnAsop6dM4vowYNOMCe4PUe_W8AXu6iIzHmQxm5AVatyPoRY4dR-Il1tswbUr5FlVGzJJsw7JLNd46FYp2gIfhDM52cgBMeH5qNQw9inUm-BUybT1rB-kB1UCNq_3WenGoTGZsJ32QSBXAS9pbjOYNHIrylR51GV2foxqcOpsgIBFt_udnWlsqkezRun7TQ

6. Attach the JWT to the request header

Before we send the request, ensure PKCV is correctly configured on your Account. Once you have Security or Enterprise Editions of your Twilio account, the PKCV setting can be found under General Settings.

Once this setting is enabled, all requests using just Basic Authentication will fail.

Settings for enabling or disabling public key client validation in Twilio

With this setting disabled, PKCV can still be used, and requests made using Basic authentication will also be allowed.

Now what we have all been waiting for - let’s use PKCV to list Call Logs!

http_method = 'GET'
query_string = 'Status=no-answer'
path = f'/2010-04-01/Accounts/{ACCOUNT_SID}/Calls.json?{query_string}'
headers = {
    'Host': 'api.twilio.com',
    'Authorization': f'Basic {create_basic_auth_str(API_KEY, API_SECRET)}'
}
body = ''
def send_request(http_method, path, headers, body):
    canonical_request = create_canonical_request(http_method, path, query_string, headers, body)
    jwt_token = create_jwt(canonical_request, API_KEY, ACCOUNT_SID, CREDENTIAL_SID, private_key_pem)
    headers['Twilio-Client-Validation'] = jwt_token
    url = f"https://api.twilio.com{path}"
    response = requests.request(http_method, url, headers=headers, data=body)
    return response
response = send_request(http_method, path, headers, body)
  • The JWT is included in the Twilio-Client-Validation header of the request
  • A GET request is made to https://api.twilio.com/2010-04-01/Accounts/{ACCOUNT_SID}/Calls.json using the defined querystring.
  • The Authorization header uses a Base64 encoded string where the username is the API Key generated in step 1. This same API Key is used to construct the JWT to match against request credentials.

The results aren’t spectacular - just the response as expected. A list of calls.

But the implication is much more clear: you can now ensure a request comes from a sender who is in control of the private key and that the message has not been modified in transit. 👍

Method B - Help from Twilio SDK

The Twilio Server-side SDKs fully support PKCV implementation via the built-in Validation Client.

A request made using Basic Authentication with the Twilio Python Helper Library looks like this:

import os
from twilio.rest import Client
account_sid = os.environ["TWILIO_ACCOUNT_SID"]
auth_token = os.environ["TWILIO_AUTH_TOKEN"]
client = Client(account_sid, auth_token)
calls = client.calls.list(limit=20)
for c in calls:
    print(c.sid)

With a few small changes, we can use the Validation Client instead, which is instantiated using the keys generated in Steps 1-3 above.

from twilio.http.validation_client import ValidationClient
from twilio.rest import Client
# Create a new ValidationClient with the keys we created
validation_client = ValidationClient(
    ACCOUNT_SID, API_KEY, CREDENTIAL_SID, private_key
)
# Create a REST Client using the validation_client
client = Client(
    API_KEY, API_SECRET, ACCOUNT_SID, http_client=validation_client
)
 # Make the request
messages = client.messages.list(limit=10)
    for m in messages:
        print("Message {}".format(m.sid))

The example included in the twilio-python source code “just works”.

Conclusion

By securing communications and verifying client authenticity, PKCV plays a crucial role in safeguarding systems against unauthorized access. We encourage you to integrate these practices within your infrastructure to bolster security. For further exploration, consider diving into some of the following additional resources:

Matt Coser is a Senior Field Security Engineer at Twilio. His focus is on telecom security, and empowering Twilio’s customers to build safely. Hit him up on linkedin to connect and discuss more.