Server-to-Server Authentication¶
tortoise-auth provides a lightweight server-to-server (S2S) authentication mechanism for internal service communication. Unlike user-based authentication, S2S auth does not require a user model or database -- it verifies a static bearer token defined in an environment variable.
When to use S2S authentication¶
Use S2SService when:
- An internal microservice calls your API and there is no user context.
- You need a simple shared-secret authentication without JWT overhead.
- You want token rotation without application restarts.
For user-facing authentication, use AuthService instead.
Configuration¶
Enable S2S authentication in your AuthConfig:
from tortoise_auth import AuthConfig, configure
configure(AuthConfig(
user_model="myapp.User",
s2s_enabled=True,
s2s_token_env_var="S2S_AUTH_TOKEN", # default
))
| Field | Type | Default | Description |
|---|---|---|---|
s2s_enabled |
bool |
False |
Enable S2S authentication. Must be True for S2SService to work. |
s2s_token_env_var |
str |
"S2S_AUTH_TOKEN" |
Name of the environment variable containing the valid token(s). |
Then set the environment variable in your deployment:
Warning
Never hardcode the token in source code. Always use environment variables, a secrets manager, or your deployment platform's secret management.
Basic usage¶
from tortoise_auth import S2SService
s2s = S2SService()
# Verify a token from an incoming request
result = await s2s.authenticate(token)
# result.service_name is None unless provided
The authenticate() method:
- Returns an
S2SAuthResulton success. - Raises
AuthenticationErrorif the token does not match. - Raises
ConfigurationErrorif S2S is not enabled or the env var is missing.
Passing a service name¶
You can pass an optional service_name for auditing:
result = await s2s.authenticate(token, service_name="billing-service")
print(result.service_name) # "billing-service"
Token rotation¶
The environment variable supports comma-separated tokens for zero-downtime rotation:
Both tokens are accepted. To rotate:
- Add the new token alongside the old one:
new-token,old-token - Deploy all services with the new token as the caller.
- Remove the old token:
new-token
Tokens are read from the environment on every call, so rotation takes effect without restarting the application.
Tip
Whitespace around tokens is automatically stripped, so
"token-a , token-b" works as expected.
Events¶
S2SService emits two events for auditing:
| Event | Keyword arguments | Description |
|---|---|---|
s2s_auth_success |
service_name |
A token was successfully verified. |
s2s_auth_failed |
service_name |
A token verification failed. |
from tortoise_auth import on
@on("s2s_auth_success")
async def log_s2s(*, service_name):
print(f"S2S auth from {service_name}")
@on("s2s_auth_failed")
async def alert_s2s(*, service_name):
print(f"S2S auth FAILED from {service_name}")
Starlette integration¶
tortoise-auth ships a ready-made Starlette authentication backend for S2S.
Setting up the middleware¶
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
from tortoise_auth.integrations.starlette import S2SAuthBackend
app = Starlette(routes=[...])
app.add_middleware(AuthenticationMiddleware, backend=S2SAuthBackend())
When a valid Bearer token is present, request.user is a ServiceIdentity
instance. When the token is missing or invalid, request.user is an
AnonymousUser.
ServiceIdentity¶
The ServiceIdentity object represents an authenticated service:
| Property | Type | Description |
|---|---|---|
is_authenticated |
bool |
Always True. |
is_anonymous |
bool |
Always False. |
display_name |
str |
The service name, or "service" if not provided. |
service_name |
str \| None |
The value of the X-Service-Name header, if present. |
Service name header¶
By default, S2SAuthBackend reads the X-Service-Name header to identify
the calling service. This is optional and purely for auditing.
# Custom header name
backend = S2SAuthBackend(service_name_header="X-Caller-Service")
# Disable service name extraction
backend = S2SAuthBackend(service_name_header=None)
Custom scopes¶
The default scopes are ("authenticated", "s2s"):
Using require_s2s()¶
The require_s2s() helper extracts the ServiceIdentity from the request
or raises AuthenticationError. It also rejects requests authenticated with
a user token (as opposed to an S2S token).
from tortoise_auth.integrations.starlette import require_s2s
async def internal_endpoint(request):
svc = require_s2s(request)
return JSONResponse({"caller": svc.display_name})
Complete example¶
import os
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from tortoise_auth import AuthConfig, configure
from tortoise_auth.integrations.starlette import S2SAuthBackend, require_s2s
async def health(request: Request) -> JSONResponse:
"""Public health check."""
return JSONResponse({"status": "ok"})
async def internal_sync(request: Request) -> JSONResponse:
"""Internal endpoint protected by S2S auth."""
svc = require_s2s(request)
return JSONResponse({"caller": svc.display_name, "data": "sensitive"})
app = Starlette(
routes=[
Route("/health", health),
Route("/internal/sync", internal_sync),
],
)
app.add_middleware(AuthenticationMiddleware, backend=S2SAuthBackend())
@app.on_event("startup")
async def startup() -> None:
configure(AuthConfig(
user_model="myapp.User",
s2s_enabled=True,
))
Test it:
# Set the token
export S2S_AUTH_TOKEN="my-internal-secret"
# Public endpoint -- no auth needed
curl http://localhost:8000/health
# Internal endpoint -- requires S2S token
curl http://localhost:8000/internal/sync \
-H "Authorization: Bearer my-internal-secret" \
-H "X-Service-Name: billing-service"
Security considerations¶
- Constant-time comparison -- token verification uses
hmac.compare_digestto prevent timing attacks. - Tokens are never stored -- the token lives only in the environment variable and is compared in memory. Nothing is written to the database.
- Environment-only -- the token value never appears in
AuthConfig, logs, or serialized config. It is read fromos.environon each request.
API reference¶
S2SService¶
| Parameter | Type | Default | Description |
|---|---|---|---|
config |
AuthConfig \| None |
None |
Optional config. Falls back to global config. |
S2SService.authenticate¶
| Parameter | Type | Default | Description |
|---|---|---|---|
token |
str |
(required) | The bearer token to verify. |
service_name |
str \| None |
None |
Optional caller identifier for auditing. |
Returns: S2SAuthResult on success.
Raises:
ConfigurationError-- S2S not enabled or env var not set.AuthenticationError-- token does not match.
S2SAuthResult¶
| Field | Type | Description |
|---|---|---|
service_name |
str \| None |
The service name passed to authenticate(). |