How to create sessions correctly
0-mcp does not ship a /login endpoint โ your project decides how users authenticate. But the session payload it expects is opinionated. Get it right and everything (auth, multi-tenant, timezone, ownership) just works. Get it wrong and you'll spend an hour debugging a 401 you don't understand.
The contract
A session is a JSON document stored in Redis under sessions:<session_key>. 0-mcp reads it, parses it, and uses the fields below directly.
{
"user": {
"id": 42, // required
"email": "[email protected]", // recommended
"name": "Jane", // optional
"timezone": "America/Sao_Paulo", // recommended (default 'UTC')
"is_admin": false,
"is_owner": false,
"locale": "en",
"preferences": {},
"token": "..." // required if MCP['ENFORCE_TOKEN'] is True
},
"account": {
"id": 7, // required for multi-tenant
"name": "Acme"
}
}Field-by-field reference
| Path | Required | Used for |
|---|---|---|
user.id | โ | self.user['id']. Used by owner_field, auto-fill (created_by, updated_by), audit logs. |
user.email | recommended | Display, audit, downstream services. |
user.timezone | recommended | Activated for the request via timezone.activate(). Drives date filters, Filter, Metrics aggregations. Defaults to 'UTC'. |
user.token | only if MCP['ENFORCE_TOKEN'] is True | Secret used to sign/verify the X-Token HMAC anti-replay header. |
account.id | required for multi-tenant | aset_tenant(account_id) switches the active DB connection. |
account.name | optional | Display only. |
A missing account.id does not raise โ it just skips the tenant switch. If your project relies on multi-tenant, the request will silently query the master DB instead.
Writing a /login endpoint
A typical login flow:
import json
import secrets
from django.http import JsonResponse
from zeromcp import BaseResource, get_master_user
from zeromcp.redis_config import get_redis, KEY_PREFIX
from zeromcp.settings_helper import get_cookie_id
COOKIE_ID = get_cookie_id()
SESSION_TTL_SECONDS = 7 * 24 * 3600 # one week
class LoginResource(BaseResource):
authenticated = False
allowed_methods = ['post']
async def post(self, request):
body = json.loads(request.body)
user = await get_master_user(body['email'], body['password'])
# build the session payload โ must match the contract
session = {
'user': {
'id': user.id,
'email': user.email,
'name': user.name,
'timezone': user.timezone or 'UTC',
'is_admin': user.is_admin,
'is_owner': user.is_owner,
'locale': user.locale,
'preferences': user.preferences,
'token': secrets.token_urlsafe(32), # signing secret for X-Token
},
'account': {
'id': user.account.id,
'name': user.account.name,
},
}
session_key = secrets.token_urlsafe(24)
redis = get_redis()
await redis.setex(
f'{KEY_PREFIX}sessions:{session_key}',
SESSION_TTL_SECONDS,
json.dumps(session),
)
response = JsonResponse({'success': True, 'user': session['user']})
response.set_cookie(
COOKIE_ID, session_key,
max_age=SESSION_TTL_SECONDS,
httponly=True, secure=True, samesite='Lax',
)
return responseCookie requirements
The session cookie value is validated against ^[a-zA-Z0-9_\-:]{5,100}$ before any Redis lookup. Stick to URL-safe characters. secrets.token_urlsafe() is a safe default.
| Cookie attribute | Recommended |
|---|---|
httponly | โ True โ keeps the cookie out of JavaScript |
secure | โ True in production (HTTPS only) |
samesite | Lax for browsers, None (with Secure) only if you need cross-site |
max_age | match SETEX TTL on Redis |
Logout
async def logout(self, request, match=None):
session_key = request.COOKIES.get(COOKIE_ID)
if session_key:
redis = get_redis()
await redis.delete(f'{KEY_PREFIX}sessions:{session_key}')
response = JsonResponse({'success': True})
response.delete_cookie(COOKIE_ID)
return responseRefreshing the session
Two options:
- Sliding window โ cookie sessions already bump their TTL automatically on every authenticated request via
GETEX. API-key session caches also slide automatically. - Hard expiry โ the cookie/Redis TTL match. Users log in again when it expires.
Sliding is the default UX for browser apps in 0-mcp today. Hard expiry is still fine when your login flow chooses not to refresh the session blob itself.
Updating user data after login
When the user changes their name, plan, role โ anything inside the session โ re-write the session blob:
async def update_session(session_key, mutate):
redis = get_redis()
key = f'{KEY_PREFIX}sessions:{session_key}'
raw = await redis.get(key)
if not raw:
return
session = json.loads(raw)
mutate(session)
ttl = await redis.ttl(key) or SESSION_TTL_SECONDS
await redis.setex(key, ttl, json.dumps(session))Or invalidate the session and force re-login. Pick your trade-off.
Common mistakes
- No
user.idโowner_fieldand audit hooks crash withKeyError. - No
account.idin multi-tenant โ queries silently hit the master DB. user.timezoneset to a non-IANA string โ date filters break.- Cookie not
httponlyโ XSS can steal session keys. - Cookie value with characters outside
[a-zA-Z0-9_\-:]โvalidate_session_keyreturnsNone, request becomes anonymous. - Cookie TTL outlives the Redis TTL โ user sees random 401s.
MCP['ENFORCE_TOKEN']isTruebutuser.tokenmissing โ everyX-Tokenvalidation fails.- Resource declares
cache_scope_fieldsbut the session payload omits the field โ framework logs aWARNINGand disables cache for that request (no read, no write). Treat scope fields as part of the session contract and watch for these warnings during rollout โ endpoints that should be cached but aren't are a sign that the session payload needs backfill.
Build one helper that issues sessions and call it from /login, /signup, /password-reset, /sso-callback. Centralizing the session payload is the single biggest win for a long-lived project.
0-mcp by Stamatios Stamou Jr โ github.com/ssjunior/0-mcp