Skip to content

Security: signalwire/signalwire-python

Security

docs/security.md

Security Configuration Guide

This guide covers the security features and configuration options available in the SignalWire SDK. It covers both SWML-based services and the standalone Search Service. SWML (SignalWire Markup Language) is the JSON document format that defines agent behavior.

Overview

The SDK provides a unified security configuration system that ensures consistent security behavior across all services. All security settings are controlled through environment variables, with secure defaults that can be overridden as needed.

Quick Start

Basic HTTPS Setup

To enable HTTPS for any service:

export SWML_SSL_ENABLED=true
export SWML_SSL_CERT_PATH=/path/to/cert.pem
export SWML_SSL_KEY_PATH=/path/to/key.pem
export SWML_DOMAIN=yourdomain.com

Basic Authentication

Basic authentication is enabled by default with auto-generated credentials. To set custom credentials:

export SWML_BASIC_AUTH_USER=myusername
export SWML_BASIC_AUTH_PASSWORD=mysecurepassword

Environment Variables

SSL/TLS Configuration

Variable Default Description
SWML_SSL_ENABLED false Enable HTTPS (true, 1, yes to enable)
SWML_SSL_CERT_PATH - Path to SSL certificate file
SWML_SSL_KEY_PATH - Path to SSL private key file
SWML_DOMAIN - Domain name for SSL (used for URL generation)
SWML_SSL_VERIFY_MODE CERT_REQUIRED SSL verification mode

Outbound TLS Trust (custom CA bundles)

These govern the trust root used for the SDK's outbound connections to SignalWire. When set, the named PEM file becomes the CA bundle used to verify the server certificate; when unset, the system trust store is used. TLS verification is never disabled: an unset value falls back to the default trusted roots, never to "no verification". Use these for private/self-signed deployments or corporate TLS inspection.

Variable Default Description
SIGNALWIRE_RELAY_CA_FILE (system trust store) Path to a CA bundle (PEM) trusted for the RELAY WebSocket (wss://) connection
SIGNALWIRE_REST_CA_FILE (system trust store) Path to a CA bundle (PEM) trusted for the REST (https://) client

Authentication

Variable Default Description
SWML_BASIC_AUTH_USER signalwire Basic auth username
SWML_BASIC_AUTH_PASSWORD auto-generated Basic auth password (43-char token if not set)
SIGNALWIRE_SIGNING_KEY unset Signing Key for inbound webhook signature validation. See Webhook Signature Validation.
SIGNALWIRE_SWAIG_SECRET random per process Secret used to sign this agent's outbound SWAIG function tokens. See SWAIG Function Token Signing.

Security Headers and Policies

Variable Default Description
SWML_USE_HSTS true Enable HSTS when HTTPS is active
SWML_HSTS_MAX_AGE 31536000 HSTS max-age in seconds (1 year)
SWML_ALLOWED_HOSTS * Comma-separated list of allowed hosts
SWML_CORS_ORIGINS * Comma-separated list of allowed CORS origins

Request Limits

Variable Default Description
SWML_MAX_REQUEST_SIZE 10485760 Maximum request size in bytes (10MB)
SWML_RATE_LIMIT 60 Requests per minute limit
SWML_REQUEST_TIMEOUT 30 Request timeout in seconds

Service-Specific Usage

SWML Services (AgentBase)

SWML-based services automatically use the unified security configuration:

from signalwire import AgentBase

class MyAgent(AgentBase):
    def __init__(self):
        super().__init__(name="secure-agent", route="/agent")
        # Security is automatically configured from environment

# The agent will use HTTPS if SWML_SSL_ENABLED=true
agent = MyAgent()
agent.run()

Search Service

The standalone search service also supports the same security configuration:

from signalwire.search import SearchService

# Basic usage - security configured from environment
service = SearchService(port=8001, indexes={"docs": "index.swsearch"})
service.start()

# Override SSL settings programmatically
service.start(
    host="0.0.0.0",
    port=8001,
    ssl_cert="/path/to/cert.pem",
    ssl_key="/path/to/key.pem"
)

Security Headers

When HTTPS is enabled, the following security headers are automatically added to responses:

  • Strict-Transport-Security: Forces HTTPS connections (when SWML_USE_HSTS=true)
  • X-Content-Type-Options: nosniff: Prevents MIME type sniffing
  • X-Frame-Options: DENY: Prevents clickjacking
  • X-XSS-Protection: 1; mode=block: Enables XSS filtering
  • Referrer-Policy: strict-origin-when-cross-origin: Controls referrer information

CORS Configuration

Cross-Origin Resource Sharing (CORS) is configured to:

  • Allow credentials
  • Allow all methods by default
  • Allow all headers by default
  • Origins controlled by SWML_CORS_ORIGINS

To restrict CORS to specific domains:

export SWML_CORS_ORIGINS="https://app1.example.com,https://app2.example.com"

Host Validation

By default, all hosts are allowed (SWML_ALLOWED_HOSTS=*). To restrict to specific hosts:

export SWML_ALLOWED_HOSTS="example.com,api.example.com"

Best Practices

1. Production HTTPS Setup

For production environments, always enable HTTPS:

# Production configuration
export SWML_SSL_ENABLED=true
export SWML_SSL_CERT_PATH=/etc/ssl/certs/server.crt
export SWML_SSL_KEY_PATH=/etc/ssl/private/server.key
export SWML_DOMAIN=api.yourdomain.com
export SWML_ALLOWED_HOSTS=api.yourdomain.com
export SWML_CORS_ORIGINS=https://app.yourdomain.com

2. Strong Authentication

Always set strong credentials in production:

export SWML_BASIC_AUTH_USER=api_user
export SWML_BASIC_AUTH_PASSWORD=$(openssl rand -base64 32)

3. Certificate Management

Follow these practices for certificates:

  • Use certificates from a trusted CA in production
  • For development, you can generate self-signed certificates:
# Generate self-signed certificate for development
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes

4. Rate Limiting

Adjust rate limits based on your usage patterns:

# Higher rate limit for internal services
export SWML_RATE_LIMIT=300

# Lower rate limit for public APIs
export SWML_RATE_LIMIT=20

5. Monitoring

Monitor security-related logs:

# Security events are logged with structured data
# Look for log entries with:
# - "security_config_loaded" - Configuration details
# - "ssl_config_invalid" - SSL configuration errors
# - "starting_search_service" / "starting_server" - Service startup with security info

Migration Guide

From Previous Versions

The security configuration is backward compatible. Existing environment variables continue to work:

  • SWML_SSL_ENABLED, SWML_SSL_CERT_PATH, SWML_SSL_KEY_PATH - Still supported
  • SWML_BASIC_AUTH_USER, SWML_BASIC_AUTH_PASSWORD - Still supported
  • Auto-generated credentials if not set - Still works the same way

New Features

The following are new security features:

  1. Search Service HTTPS: The search service now supports HTTPS using the same environment variables
  2. Security Headers: Automatically added when appropriate
  3. CORS Configuration: Fine-grained control over CORS origins
  4. Host Validation: Restrict which hosts can access the service
  5. Rate Limiting: Built-in rate limiting support
  6. HSTS: HTTP Strict Transport Security for HTTPS connections

Troubleshooting

SSL Certificate Issues

If you see SSL configuration errors:

  1. Check file paths exist and are readable:

    ls -la $SWML_SSL_CERT_PATH $SWML_SSL_KEY_PATH
  2. Verify certificate validity:

    openssl x509 -in $SWML_SSL_CERT_PATH -text -noout
  3. Check for matching key and certificate:

    openssl x509 -noout -modulus -in $SWML_SSL_CERT_PATH | openssl md5
    openssl rsa -noout -modulus -in $SWML_SSL_KEY_PATH | openssl md5

Authentication Issues

If authentication fails:

  1. Check credentials are set correctly:

    echo "User: $SWML_BASIC_AUTH_USER"
    echo "Pass length: ${#SWML_BASIC_AUTH_PASSWORD}"
  2. Don't look for the password in the startup log. The log shows the username and (credentials configured) in place of the password, even when the SDK generated it. To use a password you know, set SWML_BASIC_AUTH_USER and SWML_BASIC_AUTH_PASSWORD before you start the service. In code, get_basic_auth_credentials() returns the credentials in use.

  3. Test with curl against a route that requires authentication. The /health endpoint doesn't require it, so a successful request there doesn't confirm your credentials:

    curl -u "$SWML_BASIC_AUTH_USER:$SWML_BASIC_AUTH_PASSWORD" https://localhost:3000/your-agent-route

CORS Issues

If you encounter CORS errors:

  1. Check the origin is allowed:

    echo $SWML_CORS_ORIGINS
  2. For development, you can temporarily allow all origins:

    export SWML_CORS_ORIGINS="*"
  3. For production, specify exact origins:

    export SWML_CORS_ORIGINS="https://app.example.com,https://admin.example.com"

Webhook Signature Validation

SignalWire signs every outbound webhook (SWML callbacks, SWAIG dispatch, post-prompt summaries, RELAY async events) with an HMAC. The signature is derived from a Signing Key the customer copies from the Dashboard's API Credentials page. With the key set, an agent checks the signature before acting on a request.

How the signature is computed

The SDK validates these schemes for you, with a constant-time comparison. You need the details only to validate requests yourself, or to work out why one was refused.

Headers. X-SignalWire-Signature carries a SHA-1 signature. Newer platform builds also send X-SignalWire-Sha256-Signature, and the agent checks it first when it's present. For cXML compatibility, X-Twilio-Signature is accepted in place of X-SignalWire-Signature.

Scheme A: JSON requests (SWML, SWAIG, post-prompt summaries, RELAY events). The signature is the lowercase hex HMAC of the full URL SignalWire POSTed to, followed by the raw request body:

signature        = hex(HMAC-SHA1(signing_key, url + raw_body))
sha256 signature = hex(HMAC-SHA256(signing_key, url + raw_body))

url is exactly what the platform called: scheme, host, any non-standard port, path and query string. raw_body is the body as sent, before JSON parsing. Parsing and re-serializing it changes the bytes and breaks the signature.

Scheme B: form-encoded requests (cXML and other compatibility endpoints). The form parameters are sorted by name, and each name and value is appended to the URL; a repeated name keeps its values in their original order. The signature is the standard base64 HMAC-SHA1 of that string. The platform signs some requests with the default port in the URL (:443 or :80) and some without, so the validator tries both. When JSON is posted to a compatibility endpoint, the URL carries a bodySHA256 query parameter: the signature covers that URL with no form parameters, and the body's SHA-256 hex digest must equal the parameter.

Test vectors. validate_webhook_signature() accepts the first and third rows, and validate_request() accepts the second, with its form parameters passed as a dict:

Scheme Signing key URL Body Signature
A PSKtest1234567890abcdef https://example.ngrok.io/webhook {"event":"call.state","params":{"call_id":"abc-123","state":"answered"}} c3c08c1fefaf9ee198a100d5906765a6f394bf0f
B, form 12345 https://mycompany.com/myapp.php?foo=1&bar=2 CallSid=CA1234567890ABCDE, Caller=+14158675309, Digits=1234, From=+14158675309, To=+18005551212 RSOYDt4T1cUTdK1PDd93/VVr8B8=
B, JSON PSKtest1234567890abcdef https://example.ngrok.io/webhook?bodySHA256=69f3cbfc18e386ef8236cb7008cd5a54b7fed637a8cb3373b5a1591d7f0fd5f4 {"event":"call.state"} dfO9ek8mxyFtn2nMz24plPmPfIY=

What it doesn't cover. The signature has no timestamp, so it doesn't stop a captured request from being sent again. Make side effects idempotent, and use the per-call tool tokens described under SWAIG Function Token Signing.

AgentBase: enable validation

Pass signing_key to the AgentBase constructor (or set SIGNALWIRE_SIGNING_KEY in the environment):

from signalwire import AgentBase

agent = AgentBase(
    name="my-agent",
    signing_key="PSK...",        # or set SIGNALWIRE_SIGNING_KEY env var
    trust_proxy_for_signature=False,  # opt in if you control the proxy
)
agent.serve()

When signing_key is set, signature validation is auto-mounted on POST /, POST /swaig, POST /post_prompt. Requests without a valid X-SignalWire-Signature header are rejected with HTTP 403, and the handler is never invoked. The X-Twilio-Signature header is accepted as an alias for cXML compatibility.

The check applies however the agent is served: serve(), get_app(), mount(), AgentServer, or a serverless platform. It applies on every path that reaches those handlers. That includes the agent's route without a trailing slash, and any routing-callback path, which renders SWML like the root. On a serverless platform every POST is checked, whatever its path. On a serverless platform the URL is rebuilt the same way. It uses SWML_PROXY_URL_BASE if set, then the forwarded headers if you opted in with trust_proxy_for_signature, then the URL the platform reports. If signed requests are refused, set SWML_PROXY_URL_BASE to the public URL.

API Gateway REST APIs (payload version 1.0) hand Lambda the query parameters decoded, and not always in their original order. The SDK tries the two common encodings, but a URL with several query parameters may not be rebuilt exactly, and its signature is then refused. To validate signatures on Lambda, serve the agent from a Lambda function URL or an HTTP API (payload version 2.0), which pass the raw query string.

When signing_key is unset, AgentBase logs a prominent warning when it starts serving, or when it's created if logging is already set up:

[signalwire] webhook signature validation is disabled — set signing_key or SIGNALWIRE_SIGNING_KEY to enable

This is intentional: silently accepting unsigned webhooks in production is a footgun. Set the key, or accept the warning if you're behind a private network and have a documented reason.

Standalone validator (custom servers)

If you're not using AgentBase, the validator function is exposed directly:

from signalwire.core.security import validate_webhook_signature

ok = validate_webhook_signature(
    signing_key="PSK...",
    signature=request.headers["X-SignalWire-Signature"],
    url="https://my-public-host.example.com/webhook",
    raw_body=raw_request_body_bytes.decode("utf-8"),
)
if not ok:
    abort(403)

A legacy alias validate_request(signing_key, signature, url, params_or_raw_body) is provided for users migrating from the old @signalwire/compatibility-api shape. Pass a string raw body for the combined validator, or a pre-parsed dict for direct Scheme B (form-encoded).

URL reconstruction behind proxies

The validator signs against the URL SignalWire POSTed to, which differs from what your app sees behind a reverse proxy. The middleware honors:

  1. SWML_PROXY_URL_BASE env var (highest priority).
  2. X-Forwarded-Proto / X-Forwarded-Host headers when trust_proxy_for_signature=True.
  3. request.url (FastAPI's view) as fallback.

Proxy headers are spoofable, so opt in only when you control the entire proxy chain.

Error modes

Condition Behavior
Valid signature request passes through
Invalid signature HTTP 403, handler not called
Missing header HTTP 403, handler not called
Empty signing key (programming error) ValueError at construction
Non-string raw body (programming error) TypeError from validator

The validator never logs the signing key, the expected signature, or which scheme branch matched/failed.

Security Checklist

Before deploying to production:

  • HTTPS enabled with valid certificates
  • Strong authentication credentials set
  • CORS origins restricted to known domains
  • Host validation configured
  • Rate limits appropriate for usage
  • Security headers verified in responses
  • Logs monitored for security events
  • SSL certificate expiration monitoring in place
  • signing_key (or SIGNALWIRE_SIGNING_KEY env) configured on every AgentBase
  • Regular security updates applied

SWAIG Function Token Signing

Each SWAIG function call an agent hands to the AI carries a short-lived token that the agent signs and later verifies itself, so a function URL cannot be replayed or called out of context.

A secure function (the default, secure=True) runs only when the request carries a valid token for that function and that call. A request with no token is refused exactly like one with a wrong token, because the token is part of the URL the SWML hands out: a request without it didn't come from that SWML. Mark a function secure=False only if anyone holding the basic-auth credentials may call it.

The post-prompt URL carries a token for its call too, and a summary POSTed without it is refused, as is one whose URL and body name different calls. The token is checked against the agent that runs the function, including a copy made by per-call configuration, so a secure tool that configuration registers needs its token too. The same rules hold on serverless platforms.

To call a secure function by hand, fetch the SWML with ?call_id=<id> and POST to that function's web_hook_url, with the same call_id in the body. swaig-test calls functions directly and needs no token.

By default that secret is generated randomly per process. Tokens therefore stop verifying whenever the agent restarts, and every replica of the same agent signs with a different key.

The failure often goes unnoticed and points away from its cause. A call placed before a restart keeps running; its next tool call arrives carrying a token the new process cannot verify; and the caller is told:

the security token for this function is invalid or expired

which reads as though the tool failed, rather than as though it was never allowed to run. Nothing errors server-side and nothing logs a mismatch.

Set the secret explicitly whenever an agent restarts while calls are live (which includes every rolling deploy), and always when more than one replica serves the same agent:

from signalwire import AgentBase

agent = AgentBase(
    name="my-agent",
    swaig_secret="a-long-random-string",  # or set SIGNALWIRE_SWAIG_SECRET
)
agent.serve()

Resolution order is the constructor argument, then SIGNALWIRE_SWAIG_SECRET, then a fresh random secret.

Treat it like any other signing secret: keep it out of source control, and use the same value across every replica of one agent. It is unrelated to signing_key / SIGNALWIRE_SIGNING_KEY, which validates inbound webhooks and is issued by SignalWire; this one is the agent's own and never leaves the process.

There aren't any published security advisories