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.
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:
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer 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
oopenssl genrsa -out auth_private_key.pem 2048
oopenssl 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
oopenssl ecparam -genkey -name prime256v1 -noout -out auth_private_key.pem
oopenssl 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": "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: Always sayna-auth so your service knows who issued the JWT.
iat / exp: Issued-at plus 5-minute expiration.
auth_data.token: Bearer token extracted from the Authorization header.
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
- Any future REST routes you add to the protected router
Public endpoints today:
GET / (health)
GET /ws (WebSocket handshake — see the WebSocket Auth roadmap below)
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
curl -X GET http://localhost:3001/voices \
-H "Authorization: Bearer $AUTH_API_SECRET"
# JWT-based
curl -X POST http://localhost:3001/speak \
-H "Authorization: Bearer user-jwt-token" \
-H "Content-Type: application/json" \
-d '{"text": "Different user"}'
Error responses
{
"error": "error_code",
"message": "Human-readable error message"
}
| Error code | HTTP status | Description |
|---|
missing_auth_header | 401 | Authorization header missing. |
invalid_auth_header | 401 | Header format not Bearer <token>. |
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.