Authentication
Two built-in mechanisms (cookie + X-Api-Key) plus an opt-in Bearer module. They share the same downstream context (self.user, self.account, tenant DB) โ once authenticated, the rest of dispatch does not care which one was used. Cookie sessions and resolved X-Api-Key sessions are Redis-backed by the framework; Bearer delegates storage/validation to your resolver (DB, JWT, Redis, introspection โ your call). Bearer is described separately on Bearer tokens.
Decision tree
When a request arrives, dispatch picks an authentication source in this order:
Authorization: Bearer <token>present andMCP['BEARER_RESOLVER']configured โ Bearer flow (details)- Otherwise,
X-Api-Keyheader present โ API key flow - Otherwise, session cookie (named after
COOKIE_ID) โ session flow - Otherwise, no session
When BEARER_RESOLVER is configured and REQUIRE_VALID_BEARER=True, Bearer becomes the only accepted credential for authenticated routes โ X-Api-Key and cookie return 401. See Bearer tokens for the full matrix.
If authenticated = True on the resource (default) and no session was resolved, dispatch raises HTTPException(401, 'Not authorized') before any handler runs.
Session flow
1. Cookie validation
The session cookie value is checked against a strict regex (SESSION_KEY_PATTERN = ^[a-zA-Z0-9_\-:]{5,100}$) before any Redis lookup. Malformed cookies return None immediately โ they never reach the cache or the DB.
2. Redis lookup
Sessions live under sessions:<session_key> (with REDIS_PREFIX if configured). Missing key โ no session.
3. Activation
The JSON value is loaded and the resource activates it:
self.user = session['user']self.tz = ZoneInfo(user.timezone or 'UTC')and Django timezone is activatedself.account = session['account']self.account_dbis switched viaaset_tenant(account.id)
Session shape
{
"user": {
"id": 42,
"email": "[email protected]",
"name": "Jane",
"timezone": "America/Sao_Paulo",
"token": "...",
"is_admin": false,
"is_owner": false,
"locale": "en",
"preferences": {}
},
"account": {
"id": 7,
"name": "Acme"
}
}How sessions get created is up to your project โ typically a /login endpoint validates credentials, builds the dict, writes it to Redis with a TTL, and sets the cookie.
API key flow
API keys are designed for server-to-server traffic and trusted scripts. The key format is your project's choice โ 0-mcp only requires that you can resolve a given key string to a session payload. The default resolver shipped with 0-mcp expects a self-describing four-segment format with embedded tenant id and integrity check; if that doesn't fit, drop in your own resolver via settings.
Resolution flow
- Look up
api_session:<sha256(api_key)[:16]>in Redis. - On hit, return the cached session.
- On miss, call the configured resolver โ
MCP['API_KEY_RESOLVER']if set, otherwisezeromcp.tenant.tenant._default_resolve_api_key. - The resolver returns a session dict (with
user/account) orNone. - Cache the result under
api_session:<sha256(api_key)[:16]>forAPI_SESSION_TTLseconds (default 300).
This means an API request hits the underlying storage at most once per API_SESSION_TTL window. Subsequent requests within the window are served from Redis.
Default resolver โ legacy four-segment format
Without MCP['API_KEY_RESOLVER'] set, the default resolver expects the historical format <account_id>.<uuid>.<salt>.<hash>, where hash = sha256(account_id + uuid + salt)[:HASH_LENGTH]. It validates the hash, switches to the tenant DB, finds the matching UserApi row, builds the session.
This is what every existing 0-mcp project uses today. It still works without any changes.
Custom resolver
When you want opaque tokens, JWTs, externally-issued keys or anything else, point at your own callable:
# settings.py
MCP = {
'API_KEY_RESOLVER': 'myapp.api_keys.resolve',
}
# myapp/api_keys.py
async def resolve(api_key):
"""Take a raw key string. Return a session dict or None."""
user = await UserApi.objects.filter(api_key=api_key).afirst()
if not user:
return None
return {
'user': {
'id': user.id,
'email': user.email,
'timezone': user.timezone,
},
'account': {'id': user.account_id},
}The resolver gets the raw key โ do whatever validation you want (length checks, JWT signature, deny-list, DB lookup). Return the session payload that request.user and request.account will be populated from. The Redis cache is applied automatically by get_api_session.
Required settings
TENANT_USER_API_MODEL = 'myapp.UserApi' # the model that holds api_key rows
MCP = {
'API_SESSION_TTL': 300, # cache TTL in seconds (optional)
}UserApi model shape
The minimum:
class UserApi(models.Model):
api_key = models.CharField(max_length=200, unique=True)
email = models.EmailField()
name = models.CharField(max_length=100, default='')
avatar = models.CharField(max_length=200, null=True)
is_admin = models.BooleanField(default=False)
is_owner = models.BooleanField(default=False)
locale = models.CharField(max_length=10, default='en')
preferences = models.JSONField(default=dict)
timezone = models.CharField(max_length=50, default='UTC')Issuing a key
How keys are generated is your project's responsibility. Three common approaches:
- Opaque random tokens โ
secrets.token_urlsafe(32). Simplest. The key is just a lookup index in your DB. - Self-describing with integrity check โ embed the tenant id + a hash. Lets you reject malformed keys without touching the DB. 0-mcp's default
get_api_sessionexpects this. - JWTs / signed tokens โ when you need stateless validation. Trade-off: revocation requires a deny-list.
Whichever approach you pick, store the issued string in UserApi.api_key and hand it to the customer once.
Revocation
Two ways:
- Delete the
UserApirow โ next miss after the cache expires returnsNone. Worst-case window isAPI_SESSION_TTL. - For instant revocation, also delete
api_session:<sha256(api_key)[:16]>from Redis.
Headers and HTTP semantics
| Header | Used for |
|---|---|
Cookie: <COOKIE_ID>=<session_key> | Browser session |
X-Api-Key: <key> | Server-to-server |
Authorization: Bearer <token> | Server-to-server / agents (opt-in via BEARER_RESOLVER) |
X-Token: <obfuscated> | Anti-replay (only when ENFORCE_TOKEN=True) |
Precedence when more than one is present: Bearer (if configured) > X-Api-Key > cookie. See Bearer tokens for the strict-mode matrix.
Public endpoints
Set authenticated = False on the resource. Authentication is skipped, but rate limit, abuse blocking and security middleware still apply. Useful for /login, /signup, /health and similar.
class HealthResource(BaseResource):
authenticated = False
allowed_methods = ['get']
async def get(self, request):
return {'ok': True}Token anti-replay (optional, HMAC-SHA256)
When ENFORCE_TOKEN=True, every authenticated request (except /login and /user/me) must carry an X-Token header. The token format is:
X-Token: <timestamp_ms>.<nonce>.<hmac_sha256_hex>
Where hmac_sha256_hex is HMAC-SHA256(session_token, "<timestamp_ms>:<nonce>"). The session_token is the secret you stored under user.token when you built the session.
Server-side validation
0-mcp verifies on every request:
- Token format (3 dot-separated parts).
- Nonce charset/length โ must match
^[A-Za-z0-9_\-]{1,64}$. - Timestamp drift โ must be within
max_drift_ms(30 seconds by default; configurable viaMCP['TOKEN_MAX_DRIFT_MS']). - HMAC matches โ using
hmac.compare_digestto avoid timing attacks. - Nonce uniqueness โ reserved in Redis with
SET NX PX(TTL =2 * max_drift_ms). A replayed nonce inside the window raisesHTTPException(403, 'Not allowed, replayed token').
Any failure raises HTTPException(403). Clients must generate a fresh nonce per request โ crypto.randomUUID() or secrets.token_urlsafe(16) are good defaults.
Minting tokens client-side
Use the helper make_token (also exported from 0-mcp) on the client side, or inline the same algorithm in JavaScript:
from zeromcp import make_token token = make_token(session_token, nonce='abc-123') # โ '1730000000000.abc-123.deadbeef...'
In JavaScript:
async function makeToken(sessionToken) {
const ts = Date.now();
const nonce = crypto.randomUUID();
const payload = new TextEncoder().encode(`${ts}:${nonce}`);
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(sessionToken),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, payload);
const hex = [...new Uint8Array(sig)]
.map(b => b.toString(16).padStart(2, '0')).join('');
return `${ts}.${nonce}.${hex}`;
}ENFORCE_TOKEN is opt-in and off by default. Most projects authenticate with the session cookie alone. Turn it on when you need defense-in-depth against replayed cookies (e.g. a leaked HAR file, network logger, malicious browser extension).
Trusted proxies
When the app is behind a load balancer, raw REMOTE_ADDR is the proxy. Set TRUSTED_PROXIES to a list of CIDRs, and the library will trust X-Real-IP (and X-Forwarded-For) only when the request comes from one of those CIDRs.
MCP = {
'TRUSTED_PROXIES': ['10.0.0.0/8', '172.16.0.0/12'],
}This stops anyone on the public internet from spoofing their IP through a header โ which would otherwise bypass rate limit and abuse blocking.
Trying it
With cookie
curl -b "sessionid=YOUR_KEY" https://api.example.com/users/me
With API key
curl -H "X-Api-Key: <your-token>" https://api.example.com/users
In the Scalar UI, click Authorize to set either credential before hitting "Send" on a request.
API keys are scoped to a tenant via the UserApi row that stores them. 0-mcp switches to that tenant's DB before any request runs, so a key from tenant A cannot read tenant B's data.
0-mcp by Stamatios Stamou Jr โ github.com/ssjunior/0-mcp