Sayna supports two authentication strategies. Choose the one that matches your deployment needs, or enable both. When both are configured Sayna first checks the API secret, then falls back to delegated JWT validation.
Protected HTTP endpoints accept the authentication token via either method:
- Header:
Authorization: Bearer <token>
- Query parameter:
?api_key=<token>
Both API Secret mode and JWT mode use the same token extraction behavior.
Token precedence and fallback
When both an Authorization header and an api_key query parameter are present, Sayna evaluates them in this order:
- The
Authorization header is evaluated first.
- If the header format is invalid, the server falls back to the
api_key query parameter.
If neither source provides a usable token, the request is rejected with missing_auth_header.
Quick summary
| Mode | When to use | Highlights |
|---|
| API secret | Single-tenant deployments or trusted networks | Lowest latency, no external service, bearer-token comparison against AUTH_API_SECRET. |
| JWT + auth service | Multi-tenant or context-aware authorization | Sayna signs the entire request payload, calls out to your service, and trusts the HTTP status you return. |
Toggle auth with the AUTH_REQUIRED environment variable. When it’s false, all endpoints accept unauthenticated requests.
Option A — API secret authentication
Setup steps
-
Generate a long random secret.
-
Set the environment variables:
AUTH_REQUIRED=true
AUTH_API_SECRET=your-generated-secret
-
Clients send the secret as a bearer token in the header or as a query parameter:
# Using Authorization header (recommended)
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer your-generated-secret" \
-H "Content-Type: application/json" \
-d '{"text": "Hello world"}'
# Using query parameter
curl -X POST "http://localhost:3001/speak?api_key=your-generated-secret" \
-H "Content-Type: application/json" \
-d '{"text": "Hello world"}'
Security notes
- Use at least 32 random bytes; rotate periodically and never commit secrets to VCS.
- Always terminate TLS before traffic reaches Sayna so the bearer token stays encrypted in transit.
- Pair with IP allowlists when you use this mode for back-office tools.
Option B — Delegated JWT authentication
API secret not enough? Let your own service validate tokens and return 200/401 decisions.
1. Generate signing keys
# RSA
openssl genrsa -out auth_private_key.pem 2048
openssl rsa -in auth_private_key.pem -pubout -out auth_public_key.pem
chmod 600 auth_private_key.pem
chmod 644 auth_public_key.pem
# or ECDSA
openssl ecparam -genkey -name prime256v1 -noout -out auth_private_key.pem
openssl ec -in auth_private_key.pem -pubout -out auth_public_key.pem
AUTH_REQUIRED=true
AUTH_SERVICE_URL=https://your-auth-service.com/auth
AUTH_SIGNING_KEY_PATH=/path/to/auth_private_key.pem
AUTH_TIMEOUT_SECONDS=5
3. Implement the auth service
Your service must:
- Accept
POST requests whose body is the JWT Sayna generated.
- Verify the JWT signature using the public key (RS256 or ES256).
- Validate the extracted bearer token and any contextual data.
- Return
200 OK (allow) or 401 Unauthorized (deny). Other 4xx map to 401, 5xx become 502, and timeouts surface as 503 upstream.
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 {
const payload = jwt.verify(req.body, publicKey, { algorithms: ['RS256', 'ES256'] });
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');
}
});
app.listen(3000);
Environment variables
| Variable | Required | Default | Description |
|---|
AUTH_REQUIRED | No | false | Enable or disable authentication middleware. |
AUTH_API_SECRET | Conditional | – | Shared secret for API secret auth. |
AUTH_SERVICE_URL | Conditional | – | External auth service endpoint (JWT mode). |
AUTH_SIGNING_KEY_PATH | Conditional | – | Path to private key used to sign the JWT payload. |
AUTH_TIMEOUT_SECONDS | No | 5 | Timeout for auth service requests. |
When AUTH_REQUIRED=true you must specify either AUTH_API_SECRET or both AUTH_SERVICE_URL and AUTH_SIGNING_KEY_PATH. You can configure both to let Sayna short-circuit known service-to-service traffic with the secret and route user traffic through the JWT flow.
JWT payload schema (delegated mode)
{
"sub": "sayna-auth",
"iat": 1234567890,
"exp": 1234568190,
"auth_data": {
"token": "extracted-token-from-header-or-query",
"request_body": {"text": "request body as JSON"},
"request_headers": {
"content-type": "application/json",
"user-agent": "client-agent"
},
"request_path": "/speak",
"request_method": "POST"
}
}
sub: Always sayna-auth so your service knows who issued the JWT.
iat / exp: Issued-at plus 5-minute expiration.
auth_data.token: The extracted usable token, resolved from the Authorization header or the api_key query parameter (following the precedence rule above).
auth_data.request_body: JSON payload of the HTTP request.
auth_data.request_headers: Filtered headers (excludes authorization, cookie, x-forwarded-*, x-sayna-*, host, x-real-ip).
auth_data.request_path / request_method: Full request context for policy decisions.
Verification checklist
def verify_auth_request(jwt_string, public_key):
payload = jwt.decode(jwt_string, public_key, algorithms=['RS256', 'ES256'])
if payload['sub'] != 'sayna-auth':
return 401, 'Invalid JWT subject'
now = int(time.time())
if abs(now - payload['iat']) > 60:
return 401, 'JWT too old'
auth_data = payload['auth_data']
bearer_token = auth_data['token']
if not is_valid_token(bearer_token):
return 401, 'Invalid bearer token'
return 200, 'OK'
Protected endpoints
When AUTH_REQUIRED=true the following endpoints require authentication:
POST /speak
GET /voices
POST /livekit/token
GET /livekit/rooms
GET /livekit/rooms/{room_name}
DELETE /livekit/participant
POST /livekit/participant/mute
GET /recording/{stream_id}
POST /sip/call
GET /sip/hooks
POST /sip/hooks
DELETE /sip/hooks
POST /sip/transfer
Public endpoints today:
GET / (health)
GET /ws (WebSocket handshake — see the WebSocket Auth roadmap below)
Room-scoped authorization
Beyond authentication, room-scoped endpoints enforce ownership via metadata.auth_id:
| Endpoint | Ownership check |
|---|
POST /livekit/token | Creates room with metadata.auth_id if missing; returns 403 if owned by another tenant. |
GET /livekit/rooms | Filters to rooms matching metadata.auth_id. |
GET /livekit/rooms/{room_name} | Returns 404 if metadata.auth_id doesn’t match. |
DELETE /livekit/participant | Returns 404 if metadata.auth_id doesn’t match. |
POST /livekit/participant/mute | Returns 404 if metadata.auth_id doesn’t match. |
POST /sip/call | Creates room with metadata.auth_id; returns 404 if owned by another tenant. |
POST /sip/transfer | Returns 404 if metadata.auth_id doesn’t match. |
Room names are no longer prefixed or modified. Access control is enforced entirely through the metadata.auth_id field stored in room metadata.
WebSocket authentication status
WebSocket auth is not implemented yet. The open tasks from the design document:
- Authenticate during the HTTP upgrade by reading query parameters or headers.
- Require an auth payload inside the first
config message.
- Or leave
/ws public but scope LiveKit mirroring to trusted rooms.
Today, Sayna runs with Option C (no WebSocket auth). Use network isolation or API Gateways until JWT enforcement ships.
Usage examples
# API secret — header
curl -X GET http://localhost:3001/voices \
-H "Authorization: Bearer $AUTH_API_SECRET"
# API secret — query parameter
curl -X GET "http://localhost:3001/voices?api_key=$AUTH_API_SECRET"
# JWT-based — header
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer user-jwt-token" \
-H "Content-Type: application/json" \
-d '{"text": "Different user"}'
# JWT-based — query parameter
curl -X POST "http://localhost:3001/speak?api_key=user-jwt-token" \
-H "Content-Type: application/json" \
-d '{"text": "Different user"}'
Client guidance
Recommended default: Use the Authorization: Bearer <token> header whenever possible. Header-based auth keeps tokens out of URL strings, server logs, and browser history.
When to use query parameter auth: The ?api_key=<token> query parameter is practical in environments where adding custom headers is difficult — for example, browser redirects, webhook callbacks from third-party services, or server-sent event streams.
Tokens passed as query parameters can be logged in URL logs, proxy access logs, and analytics tooling. Always use HTTPS and avoid logging full URLs when query auth is used.
Backward compatibility
- Existing header-based clients continue to work unchanged.
- Query-based auth is additive and non-breaking.
- Clients currently sending an invalid
Authorization header alongside a valid api_key query parameter will now authenticate successfully via fallback.
Scope clarification
This dual-input auth behavior applies to protected HTTP endpoints only. It does not change:
- The WebSocket auth model (see WebSocket authentication status below).
- The LiveKit webhook signature verification flow, which uses its own signing mechanism independent of bearer tokens.
Error responses
{
"error": "error_code",
"message": "Human-readable error message"
}
| Error code | HTTP status | Description |
|---|
missing_auth_header | 401 | No usable token was found in either the Authorization header or the api_key query parameter. |
invalid_auth_header | 401 | Header format is invalid and no valid api_key fallback was provided. |
unauthorized | 401 | Token validation failed. |
auth_service_error | 401 or 502 | Auth service returned 4xx/5xx. |
auth_service_unavailable | 503 | Auth service unreachable or timed out. |
config_error | 500 | Misconfigured auth flags/keys. |
jwt_signing_error | 500 | Failed to sign JWT payload. |
Mapping auth service responses
| Auth service status | Sayna response | Meaning |
|---|
200 OK | 200 OK | Token valid; request proceeds. |
401 Unauthorized | 401 Unauthorized | Invalid token; client should refresh credentials. |
Other 4xx | 401 Unauthorized | Client error mapped to unauthorized. |
5xx | 502 Bad Gateway | Temporary auth service failure. |
| Timeout / network error | 503 Service Unavailable | Auth service unreachable. |
Security considerations
- Private keys: Store private keys with
chmod 600, never commit them, and rotate periodically.
- Network security: Run Sayna + auth service over HTTPS/TLS. Consider mutual TLS between the two if you run them across trust zones.
- Token validation: Reject replayed JWTs by checking
iat; combine with short expirations.
- Error handling: Return generic error bodies to clients but log detailed errors internally for auditing.
- Replay protection: Compare
iat against now (±60 seconds) before trusting the payload.
Always verify the JWT signature and the embedded request hash before trusting delegated responses. Skipping either check makes it possible to replay tampered requests.