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:

  1. Authorization: Bearer <token> present and MCP['BEARER_RESOLVER'] configured โ†’ Bearer flow (details)
  2. Otherwise, X-Api-Key header present โ†’ API key flow
  3. Otherwise, session cookie (named after COOKIE_ID) โ†’ session flow
  4. 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:

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

  1. Look up api_session:<sha256(api_key)[:16]> in Redis.
  2. On hit, return the cached session.
  3. On miss, call the configured resolver โ€” MCP['API_KEY_RESOLVER'] if set, otherwise zeromcp.tenant.tenant._default_resolve_api_key.
  4. The resolver returns a session dict (with user/account) or None.
  5. Cache the result under api_session:<sha256(api_key)[:16]> for API_SESSION_TTL seconds (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:

Whichever approach you pick, store the issued string in UserApi.api_key and hand it to the customer once.

Revocation

Two ways:

Headers and HTTP semantics

HeaderUsed 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:

  1. Token format (3 dot-separated parts).
  2. Nonce charset/length โ€” must match ^[A-Za-z0-9_\-]{1,64}$.
  3. Timestamp drift โ€” must be within max_drift_ms (30 seconds by default; configurable via MCP['TOKEN_MAX_DRIFT_MS']).
  4. HMAC matches โ€” using hmac.compare_digest to avoid timing attacks.
  5. Nonce uniqueness โ€” reserved in Redis with SET NX PX (TTL = 2 * max_drift_ms). A replayed nonce inside the window raises HTTPException(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