Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,25 @@ await server.serve()
await server.stop()
```

### Authentication

Both `engine.serve()` and `SpeechEngineServer` automatically verify the `X-Elevenlabs-Speech-Engine-Authorization` header on every incoming connection, rejecting any requests that were not signed by ElevenLabs. The API key is read from the `AsyncElevenLabs` client (or `api_key=` on `SpeechEngineServer`), falling back to the `ELEVENLABS_API_KEY` environment variable.

#### Disabling authentication

If your server sits behind an infrastructure layer that already restricts incoming traffic to ElevenLabs (typically an IP allowlist scoped to [ElevenLabs' egress ranges](https://elevenlabs.io/docs/overview/capabilities/speech-engine#ip-allowlisting)), you can skip JWT verification by passing `disable_auth=True`:

```python
# Via engine.serve() — no api_key required when disable_auth is True
await engine.serve(port=3001, disable_auth=True, on_transcript=on_transcript)

# Or directly on SpeechEngineServer
server = SpeechEngineServer(port=3001, disable_auth=True, on_transcript=on_transcript)
await server.serve()
```

When auth is disabled the server accepts any client that can reach it and emits a `UserWarning` on startup. **Only use this if you have an IP allowlist or equivalent network-level restriction in front of the server** — without one, anyone on the internet can open a session and consume your compute and downstream LLM quota.

## Languages Supported

Explore [all models & languages](https://elevenlabs.io/docs/models).
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ dynamic = ["version"]

[tool.poetry]
name = "elevenlabs"
version = "2.55.0"
version = "2.56.0"
description = ""
readme = "README.md"
authors = []
Expand Down Expand Up @@ -43,7 +43,7 @@ pydantic = ">= 1.9.2"
pydantic-core = ">=2.18.2"
requests = ">=2.20"
typing_extensions = ">= 4.0.0"
websockets = ">=11.0"
websockets = ">=13.0"

[tool.poetry.group.dev.dependencies]
mypy = "==1.13.0"
Expand Down
4 changes: 2 additions & 2 deletions src/elevenlabs/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ def __init__(

def get_headers(self) -> typing.Dict[str, str]:
headers: typing.Dict[str, str] = {
"User-Agent": "elevenlabs/2.55.0",
"User-Agent": "elevenlabs/2.56.0",
"X-Fern-Language": "Python",
"X-Fern-SDK-Name": "elevenlabs",
"X-Fern-SDK-Version": "2.55.0",
"X-Fern-SDK-Version": "2.56.0",
**(self.get_custom_headers() or {}),
}
if self._api_key is not None:
Expand Down
18 changes: 16 additions & 2 deletions src/elevenlabs/speech_engine/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,26 @@ async def serve(
port: int = 3001,
path: typing.Optional[str] = None,
debug: bool = False,
disable_auth: bool = False,
**handlers: typing.Any,
) -> None:
"""Start a standalone WebSocket server. Blocks until stopped."""
"""Start a standalone WebSocket server. Blocks until stopped.

:param disable_auth: If ``True``, skip verification of the
``X-Elevenlabs-Speech-Engine-Authorization`` JWT on incoming
connections. **Insecure** — only enable this if the server is
protected by an IP allowlist scoped to ElevenLabs' egress
ranges. Without one, anyone on the internet can open a session
and consume your compute and downstream LLM quota.
"""
api_key = self._get_api_key()
server = SpeechEngineServer(
port=port, path=path, debug=debug, api_key=api_key, **handlers
port=port,
path=path,
debug=debug,
api_key=api_key,
disable_auth=disable_auth,
**handlers,
)
await server.serve()

Expand Down
43 changes: 37 additions & 6 deletions src/elevenlabs/speech_engine/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import http
import os
import typing
import warnings

from .session import SpeechEngineSession, _make_log, _wire_handlers
from .types import WebSocketLike
Expand All @@ -15,7 +16,8 @@ class SpeechEngineServer:
API.

Every incoming connection is verified against the ElevenLabs API using
the configured API key before being accepted.
the configured API key before being accepted, unless ``disable_auth`` is
set to ``True``.

Example::

Expand All @@ -35,12 +37,23 @@ def __init__(
path: typing.Optional[str] = None,
api_key: typing.Optional[str] = None,
debug: bool = False,
disable_auth: bool = False,
**handlers: typing.Any,
) -> None:
"""Initialize a Speech Engine server.

:param disable_auth: If ``True``, skip verification of the
``X-Elevenlabs-Speech-Engine-Authorization`` JWT on incoming
connections. **Insecure** — only enable this if the server is
protected by an IP allowlist scoped to ElevenLabs' egress
ranges. Without one, anyone on the internet can open a session
and consume your compute and downstream LLM quota.
"""
self._port = port
self._path = path
self._api_key = api_key
self._debug = debug
self._disable_auth = disable_auth
self._handlers = handlers
self._stop_event = None # type: typing.Optional[asyncio.Event]
self._server = None # type: typing.Any
Expand All @@ -63,14 +76,29 @@ async def serve(self) -> None:
"""Start the WebSocket server. Blocks until :meth:`stop` is called."""
from .resource import verify_speech_engine_jwt # noqa: E402

import websockets # noqa: E402 — keep import lazy
# Use the new asyncio API explicitly: `websockets.serve` was still
# the legacy server on websockets 13.x and only became the asyncio
# one in 14.0. The process_request callback below relies on the
# asyncio signature (connection, request) and connection.respond().
from websockets.asyncio.server import serve as _ws_serve # noqa: E402

api_key = self._api_key or os.environ.get("ELEVENLABS_API_KEY")
if not api_key:
if not api_key and not self._disable_auth:
raise RuntimeError(
"SpeechEngineServer requires an API key to verify incoming "
"connections. Pass api_key= or set the ELEVENLABS_API_KEY "
"environment variable."
"environment variable. To run without authentication, pass "
"disable_auth=True — but only behind an IP allowlist."
)

if self._disable_auth:
warnings.warn(
"SpeechEngineServer: authentication is disabled — incoming "
"connections will NOT be verified. Make sure the server is "
"protected by either IP allowlist restricting traffic to "
"ElevenLabs or custom header values.",
UserWarning,
stacklevel=2,
)

self._stop_event = asyncio.Event()
Expand All @@ -89,6 +117,9 @@ def _process_request(
http.HTTPStatus.NOT_FOUND, "not found\n"
)

if self._disable_auth:
return None

header_value = request.headers.get(
"x-elevenlabs-speech-engine-authorization"
)
Expand All @@ -103,7 +134,7 @@ def _process_request(
)

try:
verify_speech_engine_jwt(header_value, api_key)
verify_speech_engine_jwt(header_value, typing.cast(str, api_key))
except ValueError as e:
self._log("rejected connection — %s", e)
return connection.respond(
Expand All @@ -118,7 +149,7 @@ async def _handler(websocket: typing.Any) -> None:
session = self.handle_connection(websocket)
await session.run()

self._server = await websockets.serve( # type: ignore[attr-defined]
self._server = await _ws_serve(
_handler,
"",
self._port,
Expand Down
139 changes: 139 additions & 0 deletions tests/test_speech_engine_auth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"""Tests for Speech Engine JWT verification."""

import asyncio
import base64
import hashlib
import hmac
import json
import time
import typing
import warnings

import pytest
from websockets.asyncio.client import connect as ws_connect
from websockets.exceptions import InvalidStatus

from elevenlabs.speech_engine import SpeechEngineServer
from elevenlabs.speech_engine.resource import (
SpeechEngineResource,
verify_speech_engine_jwt,
Expand Down Expand Up @@ -195,3 +200,137 @@ async def test_raises_without_api_key(self, monkeypatch: pytest.MonkeyPatch) ->
server = SpeechEngineServer(port=0)
with pytest.raises(RuntimeError, match="API key"):
await server.serve()


# ---------------------------------------------------------------------------
# SpeechEngineServer — disable_auth
# ---------------------------------------------------------------------------


async def _run_server_briefly(server: SpeechEngineServer) -> asyncio.Task:
"""Start ``server`` in a background task and wait until it's listening."""
task = asyncio.create_task(server.serve())
# Give the server loop a moment to reach `await self._stop_event.wait()`.
for _ in range(50):
await asyncio.sleep(0.02)
if server._server is not None:
break
return task


def _server_port(server: SpeechEngineServer) -> int:
import socket as _socket

# websockets.serve(host="") binds both IPv4 and IPv6; on macOS the first
# socket is often IPv6. Prefer an IPv4 socket so the client can connect
# via 127.0.0.1 reliably.
ipv4 = [
s for s in server._server.sockets if s.family == _socket.AF_INET
]
chosen = ipv4[0] if ipv4 else next(iter(server._server.sockets), None)
if chosen is None:
raise RuntimeError("server has no sockets")
return int(chosen.getsockname()[1])


class TestServerDisableAuth:
@pytest.mark.asyncio
async def test_serves_without_api_key_when_disable_auth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
server = SpeechEngineServer(port=0, disable_auth=True)
task = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
task = await _run_server_briefly(server)
assert server._server is not None
finally:
await server.stop()
if task is not None:
await task

@pytest.mark.asyncio
async def test_emits_warning_when_disable_auth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
server = SpeechEngineServer(port=0, disable_auth=True)
task = None
try:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
task = await _run_server_briefly(server)
messages = [str(w.message) for w in caught]
assert any("authentication is disabled" in m for m in messages)
finally:
await server.stop()
if task is not None:
await task

@pytest.mark.asyncio
async def test_accepts_unauthenticated_connection(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)

init_ids: typing.List[str] = []

async def on_init(conversation_id: str, session: typing.Any) -> None:
init_ids.append(conversation_id)

server = SpeechEngineServer(port=0, disable_auth=True, on_init=on_init)
task = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
task = await _run_server_briefly(server)
port = _server_port(server)
async with ws_connect(f"ws://127.0.0.1:{port}") as ws:
await ws.send(
json.dumps({"type": "init", "conversation_id": "conv_1"})
)
await asyncio.sleep(0.1)
assert init_ids == ["conv_1"]
finally:
await server.stop()
if task is not None:
await task

@pytest.mark.asyncio
async def test_rejects_missing_header_when_auth_enabled(self) -> None:
server = SpeechEngineServer(port=0, api_key=TEST_API_KEY)
task = None
try:
task = await _run_server_briefly(server)
port = _server_port(server)
with pytest.raises(InvalidStatus) as exc:
async with ws_connect(f"ws://127.0.0.1:{port}"):
pass
assert exc.value.response.status_code == 401
finally:
await server.stop()
if task is not None:
await task

@pytest.mark.asyncio
async def test_rejects_invalid_jwt_when_auth_enabled(self) -> None:
server = SpeechEngineServer(port=0, api_key=TEST_API_KEY)
task = None
try:
task = await _run_server_briefly(server)
port = _server_port(server)
with pytest.raises(InvalidStatus) as exc:
async with ws_connect(
f"ws://127.0.0.1:{port}",
additional_headers={
"X-Elevenlabs-Speech-Engine-Authorization": "not.a.jwt"
},
):
pass
assert exc.value.response.status_code == 401
finally:
await server.stop()
if task is not None:
await task
Loading