Sayna supports two authentication methods for protecting API endpoints:
-
API Secret Authentication (Simple): Direct bearer token comparison against a configured list of
{id, secret}entries. Ideal for single-tenant or small multi-tenant deployments. -
JWT-Based Authentication (Advanced): Delegates token validation to an external authentication service with JWT-signed requests. Provides maximum flexibility for multi-tenant systems and complex authorization logic.
Both methods can be configured independently, and you can choose the approach that best fits your deployment requirements.
Token transport methods for protected HTTP endpoints:
Authorization: Bearer <token>?api_key=<token>query parameter
If both are present, the Authorization header is used first.
# 1. Generate a secret
openssl rand -base64 32
# 2. Configure
AUTH_REQUIRED=true
AUTH_API_SECRETS_JSON='[{"id":"default","secret":"sk_test_default_123"},{"id":"partner-1","secret":"sk_test_partner_456"}]'
# 3. Use
curl -H "Authorization: Bearer sk_test_default_123" http://localhost:3001/speak
# 3b. Or use query parameter
curl "http://localhost:3001/speak?api_key=sk_test_default_123"Legacy single-secret support is still available via AUTH_API_SECRET with optional AUTH_API_SECRET_ID.
# 1. Generate keys
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
# 2. Configure
AUTH_REQUIRED=true
AUTH_SERVICE_URL=https://your-auth.com/validate
AUTH_SIGNING_KEY_PATH=/path/to/private.pem
# 3. Implement auth service (see detailed setup below)Simple bearer token comparison against a configured list of secrets (with ids) - no external service required.
┌─────────┐ ┌───────┐
│ Client │ │ Sayna │
└────┬────┘ └───┬───┘
│ │
│ POST /speak │
│ Authorization: Bearer tk │
├─────────────────────────>│
│ │
│ │ Compare token "tk"
│ │ with configured api_secrets
│ │
│ Allow/Deny request │
│<─────────────────────────┤
│ │
When to use:
- Single-tenant deployments or small multi-tenant setups
- Simple authentication requirements
- Lightweight per-tenant auditing via secret ids
- No need for per-request authorization logic
- Lowest latency (no external service call)
Delegated validation with external auth service for advanced use cases.
┌─────────┐ ┌───────┐ ┌──────────────┐
│ Client │ │ Sayna │ │ Auth Service │
└────┬────┘ └───┬───┘ └──────┬───────┘
│ │ │
│ POST /speak │ │
│ Authorization: Bearer tk │ │
├─────────────────────────>│ │
│ │ │
│ │ Create JWT payload: │
│ │ {token, body, headers} │
│ │ │
│ │ Sign with PRIVATE key │
│ │ │
│ │ POST /auth │
│ │ Body: signed JWT │
│ ├──────────────────────────>│
│ │ │
│ │ │ Verify JWT
│ │ │ with PUBLIC key
│ │ │
│ │ │ Validate bearer
│ │ │ token "tk"
│ │ │
│ │ 200 OK / 401 │
│ │<──────────────────────────┤
│ │ │
│ Allow/Deny request │ │
│<─────────────────────────┤ │
│ │ │
When to use:
- Multi-tenant deployments
- Complex authorization logic (permissions, roles, quotas)
- Need to validate against external user database
- Context-aware authorization (different permissions per endpoint)
- Centralized authentication service
-
Authentication Middleware (
src/middleware/auth.rs)- Intercepts HTTP requests to protected endpoints
- Extracts token from
Authorization: Bearer ...or?api_key=... - API Secret Mode: Direct token comparison with configured secret list; matched id stored in
AuthContext - JWT Mode: Buffers request body/headers and calls AuthClient
- Priority: API secret checked first if configured
-
Server Config (
src/config.rs)- Loads authentication configuration from YAML and environment variables
- Validates that at least one auth method is configured when
AUTH_REQUIRED=true - Helper methods:
has_jwt_auth(),has_api_secret_auth()
-
Auth Client (
src/auth/client.rs) - JWT Mode Only- HTTP client for communicating with external auth service
- Signs auth payloads using JWT
- Handles connection pooling and timeouts
-
JWT Signing Module (
src/auth/jwt.rs) - JWT Mode Only- Signs auth request payloads with private key
- Supports RSA and ECDSA keys in PEM format
- Includes timestamp and 5-minute expiration
-
Error Handling (
src/errors/auth_error.rs)- Comprehensive error types for auth failures
- Proper HTTP status code mapping
Choose one of the two authentication methods below based on your requirements.
For simple deployments where you just need one or more shared secrets.
# Generate a random 32-character secret
openssl rand -base64 32
# Or use any secure string
# Example: sk_live_abc123xyz789...Add to your .env file:
# Enable authentication
AUTH_REQUIRED=true
# Set your API secrets (JSON array of {id, secret})
AUTH_API_SECRETS_JSON='[{"id":"default","secret":"sk_test_default_123"},{"id":"partner-1","secret":"sk_test_partner_456"}]'Legacy single-secret env vars (if you cannot set JSON):
AUTH_API_SECRET=sk_test_legacy_123
AUTH_API_SECRET_ID=defaultOr configure YAML:
auth:
required: true
api_secrets:
- id: "default"
secret: "sk_test_default_123"
- id: "partner-1"
secret: "sk_test_partner_456"Clients authenticate by sending any configured secret as a bearer token:
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer sk_test_default_123" \
-H "Content-Type: application/json" \
-d '{"text": "Hello world"}'That's it! No external service or key generation needed.
Security Notes:
- Use a long, random secret (32+ characters)
- Rotate the secret periodically
- Never commit the secret to version control
- Use HTTPS in production to protect the token in transit
For multi-tenant or complex authorization requirements.
Generate an RSA key pair for JWT signing:
# Generate private key (keep this secret!)
openssl genrsa -out auth_private_key.pem 2048
# Extract public key (share with auth service)
openssl rsa -in auth_private_key.pem -pubout -out auth_public_key.pem
# Set proper permissions
chmod 600 auth_private_key.pem
chmod 644 auth_public_key.pemAlternatively, use ECDSA keys (smaller and faster):
# Generate private key
openssl ecparam -genkey -name prime256v1 -noout -out auth_private_key.pem
# Extract public key
openssl ec -in auth_private_key.pem -pubout -out auth_public_key.pem
# Set proper permissions
chmod 600 auth_private_key.pem
chmod 644 auth_public_key.pemAdd to your .env file:
# Enable authentication
AUTH_REQUIRED=true
# JWT-based auth configuration
AUTH_SERVICE_URL=https://your-auth-service.com/auth
AUTH_SIGNING_KEY_PATH=/path/to/auth_private_key.pem
AUTH_TIMEOUT_SECONDS=5Your external authentication service must:
- Accept POST requests with JWT in the body
- Verify JWT signature using the public key
- Validate the bearer token in the JWT payload
- Return:
200 OKif token is valid401 Unauthorizedif token is invalid- Any other status code for errors
const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const app = express();
const publicKey = fs.readFileSync('auth_public_key.pem');
app.post('/auth', express.text(), (req, res) => {
try {
// Verify and decode JWT
const payload = jwt.verify(req.body, publicKey, {
algorithms: ['RS256', 'ES256']
});
// Validate the bearer token
const isValid = validateToken(payload.token);
if (isValid) {
res.status(200).send('OK');
} else {
res.status(401).send('Invalid token');
}
} catch (error) {
res.status(401).send('JWT verification failed');
}
});
function validateToken(token) {
// Implement your token validation logic here
// e.g., check database, verify JWT claims, etc.
return true; // placeholder
}
app.listen(3000);| Variable | Required | Default | Description |
|---|---|---|---|
AUTH_REQUIRED |
No | false |
Enable/disable authentication |
AUTH_API_SECRETS_JSON |
Conditional* | - | JSON array of {id, secret} entries for API Secret auth |
AUTH_API_SECRET |
Conditional* | - | Legacy single secret for API Secret auth (use AUTH_API_SECRETS_JSON instead) |
AUTH_API_SECRET_ID |
No | default |
Legacy secret id when using AUTH_API_SECRET |
AUTH_SERVICE_URL |
Conditional** | - | External auth service endpoint (JWT mode) |
AUTH_SIGNING_KEY_PATH |
Conditional** | - | Path to RSA/ECDSA private key (JWT mode) |
AUTH_TIMEOUT_SECONDS |
No | 5 |
Auth request timeout in seconds (JWT mode only) |
Configuration Requirements:
When AUTH_REQUIRED=true, you must configure at least one of:
- Option A (Simple): Set
AUTH_API_SECRETS_JSON(or legacyAUTH_API_SECRETwith optionalAUTH_API_SECRET_ID) - Option B (Advanced): Set both
AUTH_SERVICE_URLandAUTH_SIGNING_KEY_PATH
Both methods can coexist: If both are configured, API Secret is checked first (takes priority).
Protected endpoints can read the authenticated method and API secret id via Extension<AuthContext>:
use axum::{extract::Extension, response::IntoResponse};
use crate::auth::AuthContext;
async fn speak(Extension(auth): Extension<AuthContext>) -> impl IntoResponse {
if let Some(secret_id) = auth.id() {
tracing::info!(auth_id = %secret_id, "auditing request");
}
// handler logic...
}auth.id() returns None for JWT-authenticated requests; use auth.method if you need to branch on auth type.
This section applies only to JWT-based authentication (Option B). Skip this if you're using API Secret authentication.
The JWT signed by Sayna contains the following claims structure:
{
"sub": "sayna-auth",
"iat": 1234567890,
"exp": 1234568190,
"auth_data": {
"token": "bearer-token-from-auth-header",
"request_body": {"text": "request body as JSON"},
"request_headers": {
"content-type": "application/json",
"user-agent": "client-agent"
},
"request_path": "/speak",
"request_method": "POST"
}
}sub(Subject): Always set to"sayna-auth"- identifies the JWT issueriat(Issued At): Unix timestamp when the JWT was createdexp(Expiration): Unix timestamp when JWT expires (5 minutes from creation)
token: The bearer token extracted from theAuthorizationheaderrequest_body: The complete request body parsed as JSONrequest_headers: Filtered request headers (see below for exclusions)request_path: The HTTP request pathrequest_method: The HTTP method (GET, POST, etc.)
The following headers are excluded from request_headers for security:
authorization(already intokenfield)cookie(sensitive)x-forwarded-*(internal proxy headers)x-sayna-*(internal application headers)host(infrastructure)x-real-ip(infrastructure)
The following API endpoints require authentication when AUTH_REQUIRED=true:
POST /speak- Text-to-speech generationGET /voices- List available voicesPOST /livekit/token- Generate LiveKit participant token
GET /- Health check endpointGET /ws- WebSocket endpoint (see WebSocket Auth section)
The client authentication flow is identical for both methods. You can pass the token in the Authorization header or as an api_key query parameter.
# Use one of your configured API secrets as the bearer token
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer sk_test_default_123" \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "voice": "en-US-JennyNeural"}'
# List available voices
curl -X GET http://localhost:3001/voices \
-H "Authorization: Bearer sk_test_default_123"
# Same request via query parameter
curl -X GET "http://localhost:3001/voices?api_key=sk_test_default_123"The matched secret id is attached to AuthContext and logged as api_secret_id for auditing.
# Use your user's token (validated by your auth service)
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer user-jwt-token-abc123" \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "voice": "en-US-JennyNeural"}'
# The token can be different for each user
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer user-jwt-token-xyz789" \
-H "Content-Type: application/json" \
-d '{"text": "Different user"}'
# Or via query parameter
curl -X POST "http://localhost:3001/speak?api_key=user-jwt-token-xyz789" \
-H "Content-Type: application/json" \
-d '{"text": "Different user"}'curl -X POST http://localhost:3001/speak \
-H "Content-Type: application/json" \
-d '{"text": "Hello world"}'
# Returns: 401 Unauthorized - Missing authentication tokenAuthentication errors return JSON responses with the following structure:
{
"error": "error_code",
"message": "Human-readable error message"
}| Error Code | HTTP Status | Description |
|---|---|---|
missing_auth_header |
401 Unauthorized | No token provided in either Authorization header or api_key query parameter |
invalid_auth_header |
401 Unauthorized | Authorization header format is invalid and no valid api_key query parameter was provided |
unauthorized |
401 Unauthorized | Token validation failed (auth service returned 401) |
auth_service_error |
401 or 502 | Auth service returned an error (see below) |
auth_service_unavailable |
503 Service Unavailable | Auth service is unreachable or timed out |
config_error |
500 Internal Server Error | Auth configuration error (e.g., missing signing key) |
jwt_signing_error |
500 Internal Server Error | Failed to sign JWT payload |
Sayna maps auth service responses to HTTP status codes as follows:
| Auth Service Status | Sayna Response Status | Description |
|---|---|---|
| 200 OK | 200 OK (request allowed) | Token is valid, request proceeds |
| 401 Unauthorized | 401 Unauthorized | Invalid token, client should not retry |
| 4xx (other) | 401 Unauthorized | Client error mapped to unauthorized |
| 5xx | 502 Bad Gateway | Auth service error, temporary issue |
| Timeout/Network Error | 503 Service Unavailable | Auth service unreachable |
Missing Token (header/query):
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "missing_auth_header",
"message": "Missing authentication token (Authorization header or api_key query parameter)"
}Invalid Token:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "unauthorized",
"message": "Unauthorized: Invalid token signature"
}Auth Service Unavailable:
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
{
"error": "auth_service_unavailable",
"message": "Auth service unavailable: Connection timeout"
}Auth Service Error (500):
HTTP/1.1 502 Bad Gateway
Content-Type: application/json
{
"error": "auth_service_error",
"message": "Auth service error (500 Internal Server Error): Database connection failed"
}Note: Error bodies from the auth service are capped at 500 characters to prevent DoS attacks.
Your external authentication service MUST implement the following contract:
- Method: POST
- Content-Type:
application/jwt - Body: JWT string (signed with RS256 or ES256)
The auth service should return:
-
Success (200 OK)
- Status:
200 OK - Body: Optional (ignored by Sayna)
- Meaning: Token is valid, allow the request
- Status:
-
Unauthorized (401)
- Status:
401 Unauthorized - Body: Optional error message (capped at 500 chars)
- Meaning: Token is invalid, deny the request
- Status:
-
Server Errors (5xx)
- Status:
500,503, etc. - Body: Optional error message (capped at 500 chars)
- Meaning: Temporary auth service issue, Sayna returns 502
- Status:
Your auth service MUST:
- Verify JWT signature using the public key
- Check
expclaim (JWT expiration) - Validate
iatclaim is not too old (prevent replay attacks) - Extract and validate the bearer token from
auth_data.token - Optionally use
auth_data.request_body,auth_data.request_headers, etc. for context-aware authorization
def verify_auth_request(jwt_string, public_key):
try:
# Verify JWT signature and expiration
payload = jwt.decode(jwt_string, public_key, algorithms=['RS256', 'ES256'])
# Check standard claims
if payload['sub'] != 'sayna-auth':
return 401, "Invalid JWT subject"
# Check issued-at time (prevent replay attacks)
now = int(time.time())
if abs(now - payload['iat']) > 60: # Allow 60 second window
return 401, "JWT too old"
# Extract auth data
auth_data = payload['auth_data']
bearer_token = auth_data['token']
# Validate bearer token (your custom logic)
if not is_valid_token(bearer_token):
return 401, "Invalid bearer token"
# Optionally check request context
if auth_data['request_path'] == '/admin' and not is_admin(bearer_token):
return 401, "Insufficient permissions"
return 200, "OK"
except jwt.InvalidSignatureError:
return 401, "Invalid JWT signature"
except jwt.ExpiredSignatureError:
return 401, "JWT expired"
except Exception as e:
return 500, f"Internal error: {str(e)}"WebSocket authentication is currently not implemented (see action plan task 8). Options for future implementation:
Extract token from query parameters or headers during the WebSocket upgrade handshake.
const ws = new WebSocket('ws://localhost:3001/ws?token=bearer-token');Require authentication in the first WebSocket message (Config message).
Keep WebSocket open and only protect REST endpoints.
Current Implementation: Option C (no WebSocket auth)
-
Private Key Security
- Store private key with
chmod 600permissions - Never commit private key to version control
- Use environment variables for key path
- Rotate keys periodically
- Store private key with
-
Network Security
- Use HTTPS for auth service in production
- Use TLS for Sayna in production
- Consider mutual TLS between Sayna and auth service
-
Token Validation
- Implement replay attack protection in auth service
- Check JWT timestamp to reject old requests
- Validate JWT expiration
- Implement rate limiting
-
Error Handling
- Don't leak sensitive information in error messages
- Log authentication failures for security monitoring
- Monitor auth service availability
JWT signing ensures:
- Request Integrity: Auth service receives unmodified request data
- Source Authentication: Auth service knows request comes from legitimate Sayna instance
- Context-Aware Authorization: Auth service can make decisions based on full request context
The auth service should reject requests with old iat (Issued At) timestamps to prevent replay attacks:
const MAX_AGE = 60; // seconds
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - payload.iat) > MAX_AGE) {
return res.status(401).send('Request too old');
}The iat claim is a standard JWT field that indicates when the JWT was created. Combined with the exp (expiration) claim, this provides robust protection against replay attacks.
-
Check configuration:
# Verify environment variables are set echo $AUTH_REQUIRED echo $AUTH_API_SECRETS_JSON echo $AUTH_API_SECRET echo $AUTH_API_SECRET_ID
-
Verify token matches:
# Make sure you're sending the exact same string # API secret comparison is case-sensitive # Replace with one of the configured secrets (or $AUTH_API_SECRET for legacy) curl -v -X POST http://localhost:3001/speak \ -H "Authorization: Bearer sk_test_default_123" \ -H "Content-Type: application/json" \ -d '{"text": "test"}'
-
Review logs:
# Look for: "API secret authentication successful" (includes api_secret_id) # Or: "API secret authentication failed: token mismatch"
-
Check configuration:
# Verify environment variables are set echo $AUTH_REQUIRED echo $AUTH_SERVICE_URL echo $AUTH_SIGNING_KEY_PATH
-
Verify key file:
# Check file exists and has correct permissions ls -la /path/to/auth_private_key.pem # Verify key format openssl rsa -in auth_private_key.pem -check # for RSA openssl ec -in auth_private_key.pem -check # for ECDSA
-
Check auth service:
# Test auth service is reachable curl -X POST $AUTH_SERVICE_URL -d "test"
-
Review logs:
# Look for: "JWT authentication enabled" # Or: "JWT authentication failed"
| Error | Cause | Solution |
|---|---|---|
| 401 Unauthorized | Missing or invalid token | Include a valid Authorization: Bearer {token} header or ?api_key={token} query parameter |
| 401 "Invalid API secret" | Token doesn't match any configured API secret | Check token matches one of the configured secrets (case-sensitive) |
| 500 "Auth required but no method configured" | AUTH_REQUIRED=true but no auth method set | Set either AUTH_API_SECRETS_JSON (or legacy AUTH_API_SECRET) or (AUTH_SERVICE_URL + AUTH_SIGNING_KEY_PATH) |
| 503 Service Unavailable | Auth service unreachable (JWT mode) | Verify auth service is running and reachable |
- Fastest option: No external service calls, just string comparison
- Zero latency overhead: Authentication happens in microseconds
- No network dependencies: No risk of auth service downtime
- Best for: High-throughput, latency-sensitive applications
- External service latency: Adds network round-trip time to each request
- Connection Pooling: HTTP client pools connections to minimize overhead
- Timeout: Configurable timeout prevents hanging requests (default: 5s)
- Async: All auth operations are non-blocking
- Caching: Consider implementing token caching in future (not currently implemented)
-
For JWT mode - Increase timeout for slow auth services:
AUTH_TIMEOUT_SECONDS=10
-
For JWT mode - Deploy auth service close to Sayna to reduce network latency
-
For JWT mode - Monitor auth service performance and scale as needed
-
Consider API Secret if you don't need per-user authorization logic
# In .env file
AUTH_REQUIRED=falseFor testing without a real auth service, keep AUTH_REQUIRED=false or implement a mock auth service.
See tests/auth_integration_test.rs for examples of testing authentication middleware.
Use this decision guide to select the right authentication method:
✅ Single tenant or small number of known clients ✅ Same authorization level for all authenticated requests ✅ Performance/latency is critical ✅ Simple deployment without external services ✅ Quick setup and minimal configuration needed
✅ Multi-tenant application ✅ Different users need different permissions ✅ Need context-aware authorization (different permissions per endpoint) ✅ Integration with existing user management system ✅ Audit trail and detailed access logs required ✅ Token validation logic changes frequently
Yes! Both methods can coexist:
- Configure both
AUTH_API_SECRETS_JSON(or legacyAUTH_API_SECRET) andAUTH_SERVICE_URL - API Secret is checked first (takes priority)
- Useful for: admin access via secret + user access via JWT
| Approach | Pros | Cons | Use Case |
|---|---|---|---|
| API Secret (Current) | Simple, fast, no external deps, supports multiple secrets with ids | Shared-secret model, limited authorization flexibility | Simple deployments |
| JWT Validation (Current) | Flexible, per-user auth, context-aware | Requires external service, latency overhead | Complex auth requirements |
| OAuth2 Proxy | Standardized, battle-tested | Additional infrastructure, not integrated | Enterprise deployments |
| Embedded JWT | Fast, no external service | Tightly coupled, hard to update logic | Standalone apps |
Sayna's dual authentication approach combines the simplicity of API secrets with the flexibility of JWT validation, giving you the best of both worlds.