| name | implementing-scalekit-django-auth |
| description | Implements Scalekit authentication in a Django project using the patterns from scalekit-inc/scalekit-django-auth-example. Handles login, OAuth callback, Django session storage, automatic token refresh via middleware, logout, and permission-based route protection using decorators. Use when adding auth views, protecting URLs, managing sessions, or checking permissions in a Django + Scalekit codebase. |
Scalekit Auth — Django
Reference repo: scalekit-inc/scalekit-django-auth-example
Project structure
auth_app/
├── scalekit_client.py # ScalekitClient class + scalekit_client() singleton
├── views.py # All auth + protected views
├── decorators.py # @login_required, @permission_required('perm:name')
├── middleware.py # ScalekitTokenRefreshMiddleware (auto token refresh)
└── urls.py # URL patterns (app_name = 'auth_app')
scalekit_django_auth/
└── settings.py # SCALEKIT_* settings, middleware registration, session config
Environment variables
SCALEKIT_ENV_URL=https://your-env.scalekit.io
SCALEKIT_CLIENT_ID=your-client-id
SCALEKIT_CLIENT_SECRET=your-client-secret
SCALEKIT_REDIRECT_URI=http://localhost:8000/auth/callback
# SCALEKIT_SCOPES is set directly in settings.py, not from env
SCALEKIT_ENV_URL also falls back to SCALEKIT_DOMAIN for backward compatibility.
SCALEKIT_REDIRECT_URI has no trailing slash — this avoids Django redirect issues.
Django settings (settings.py)
Key non-obvious settings to include:
INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'auth_app',
]
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'auth_app.middleware.ScalekitTokenRefreshMiddleware',
]
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
SESSION_COOKIE_AGE = 3600
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_SAVE_EVERY_REQUEST = True
SCALEKIT_ENV_URL = os.getenv('SCALEKIT_ENV_URL', os.getenv('SCALEKIT_DOMAIN', ''))
SCALEKIT_CLIENT_ID = os.getenv('SCALEKIT_CLIENT_ID', '')
SCALEKIT_CLIENT_SECRET = os.getenv('SCALEKIT_CLIENT_SECRET', '')
SCALEKIT_REDIRECT_URI = os.getenv('SCALEKIT_REDIRECT_URI', 'http://localhost:8000/auth/callback')
SCALEKIT_SCOPES = 'openid profile email offline_access'
LOGIN_URL = '/login'
SDK client (auth_app/scalekit_client.py)
Lazy singleton — always use scalekit_client(), never instantiate directly:
from auth_app.scalekit_client import scalekit_client
client = scalekit_client()
SDK import paths:
from scalekit import ScalekitClient as SDKClient
from scalekit.common.scalekit import (
AuthorizationUrlOptions,
CodeAuthenticationOptions,
TokenValidationOptions,
LogoutUrlOptions,
)
Key methods on ScalekitClient:
| Method | SDK call | Returns |
|---|
get_authorization_url(state) | sdk_client.get_authorization_url(redirect_uri, options) | str URL |
exchange_code_for_tokens(code) | sdk_client.authenticate_with_code(code, redirect_uri, options) | dict with access_token, refresh_token, id_token, user, expires_in |
get_user_info(access_token) | sdk_client.validate_access_token_and_get_claims(token, options) | dict claims |
refresh_access_token(refresh_token) | sdk_client.refresh_access_token(refresh_token) | dict with access_token, refresh_token |
validate_token_and_get_claims(token) | sdk_client.validate_access_token_and_get_claims(token, options) | dict claims |
has_permission(access_token, permission) | validates claims, checks permission key chain | bool |
logout(access_token, id_token) | sdk_client.get_logout_url(options) | str URL |
Session storage schema
All auth state is stored in Django's session (no extra DB tables):
request.session['scalekit_user'] = {
'sub', 'email', 'name', 'given_name', 'family_name',
'preferred_username', 'claims'
}
request.session['scalekit_tokens'] = {
'access_token', 'refresh_token', 'id_token',
'expires_at',
'expires_in'
}
request.session['scalekit_roles'] = []
request.session['scalekit_permissions'] = []
Check authentication anywhere: request.session.get('scalekit_user') → truthy if logged in.
Auth flow
Login (login_view — GET /login/)
state = secrets.token_urlsafe(32)
request.session['oauth_state'] = state
request.session.save()
auth_url = client.get_authorization_url(state=state)
Callback (callback_view — GET /auth/callback)
- Validate
state param vs request.session['oauth_state'] → render error on mismatch
request.session.pop('oauth_state', None)
token_response = client.exchange_code_for_tokens(code)
user_obj = token_response.get('user', {}) — camelCase fields (givenName, familyName, id)
user_info = client.get_user_info(access_token) — snake_case claims for roles/permissions
- Name resolution:
user_obj.name → givenName + familyName → user_info claims → email
expires_at = timezone.now() + timedelta(seconds=expires_in)
- Write
scalekit_user, scalekit_tokens, scalekit_roles, scalekit_permissions to session
- Redirect to
auth_app:dashboard
Permission claim fallback chain (same as Node SDK):
permissions = (
claims.get('permissions', []) or
claims.get('https://scalekit.com/permissions', []) or
claims.get('scalekit:permissions', []) or
[]
)
Logout (logout_view — GET /logout/)
logout_url = client.logout(access_token, id_token)
request.session.flush()
return redirect(logout_url)
Token refresh — middleware (auth_app/middleware.py)
ScalekitTokenRefreshMiddleware runs on every request. Skipped paths:
/login, /auth/callback, /logout, /static/, /sessions/refresh-token
Buffer: 1 minute (vs 5 min in client is_token_expired helper).
Also available as a manual API endpoint: POST /sessions/refresh-token/ → JsonResponse.
Protecting views
from auth_app.decorators import login_required, permission_required
@login_required
def dashboard_view(request): ...
@permission_required('organization:settings')
def org_settings_view(request): ...
URL configuration
path('auth/callback', callback_view, name='callback'),
path('sessions/validate-token/', validate_token_view),
path('sessions/refresh-token/', refresh_token_view),
Use reverse('auth_app:dashboard') / {% url 'auth_app:login' %} in templates.
Route map
| URL | Auth | Notes |
|---|
/ | No | Redirects to dashboard if already logged in |
/login/ | No | Generates auth URL, stores CSRF state |
/auth/callback | No | No trailing slash |
/dashboard/ | @login_required | |
/logout/ | @login_required | |
/sessions/ | @login_required | |
/sessions/validate-token/ | @login_required | POST |
/sessions/refresh-token/ | @login_required | POST |
/organization/settings/ | @permission_required('organization:settings') | |
Install
pip install scalekit python-dotenv django
python manage.py migrate
python manage.py runserver
Tactics
SameSite=Lax — never Strict
SESSION_COOKIE_SAMESITE = 'Lax' is correct. Do not change to 'Strict' — it drops the session cookie on the cross-origin redirect from Scalekit back to /auth/callback, so oauth_state is unavailable and the CSRF check fails on every login.
SESSION_SAVE_EVERY_REQUEST = True — why it matters
The OAuth flow involves at least two redirects. Without SESSION_SAVE_EVERY_REQUEST = True, the session containing oauth_state may not be written to the database before Django redirects to Scalekit, causing a state mismatch on the callback. This setting ensures session writes happen on every response.
@csrf_exempt on the callback view
The OAuth callback receives a GET request from Scalekit (an external origin). Django's CSRF middleware does not block GETs, but the OAuth state parameter already serves as the CSRF token for this flow. If you ever add a POST-based callback, exempt it explicitly:
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def callback_view(request): ...
Deep link preservation
@login_required already appends ?next=<path> when redirecting. Read it in login_view and restore it after a successful callback:
next_url = request.GET.get('next', reverse('auth_app:dashboard'))
request.session['next'] = next_url
request.session.save()
next_url = request.session.pop('next', reverse('auth_app:dashboard'))
if not next_url.startswith('/'):
next_url = reverse('auth_app:dashboard')
return redirect(next_url)
Cache-Control: no-store on protected views
Without this, the back button after logout serves a cached authenticated page:
from django.views.decorators.cache import never_cache
@never_cache
@login_required
def dashboard_view(request): ...
AJAX: 401 instead of redirect
If your frontend makes AJAX calls to protected views, return 401 instead of a redirect:
from functools import wraps
from django.http import JsonResponse
def login_required_ajax(f):
@wraps(f)
def decorated(request, *args, **kwargs):
if not request.session.get('scalekit_user'):
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'error': 'Authentication required'}, status=401)
return redirect(f"{reverse('auth_app:login')}?next={request.path}")
return f(request, *args, **kwargs)
return decorated
Session fixation after login
Call request.session.cycle_key() immediately after writing session data in callback_view to prevent session fixation — an attacker who planted a known session ID before login cannot hijack the authenticated session:
request.session.cycle_key()
return redirect(next_url)