Troubleshooting¶
ImproperlyConfigured: TENANT_API_KEY_MODEL is not set¶
get_api_key_model() (and anything that calls it — the default
TenantAPIKeyAuthentication.get_model(), TenantAPIKeyAuth.get_model(),
both management commands) needs settings.TENANT_API_KEY_MODEL to be a
"app_label.ModelName" string. Set it, or subclass the authentication
class with an explicit model attribute if you'd rather not rely on the
setting — see Configuration.
ImproperlyConfigured: ... has not been installed¶
TENANT_API_KEY_MODEL points at a model that either isn't in
INSTALLED_APPS yet, or isn't a valid app_label.ModelName string (a typo,
missing dot, wrong casing). Check the app is installed and the string
matches your model's actual app label and class name.
A key I just created won't authenticate¶
Almost always one of two things:
- You're comparing against
instance.hashed_keyinstead of the raw key.generate_key()returns(instance, raw_key)—raw_keyis what a client sends in theAuthorizationheader.instance.hashed_keyis what ends up in the database and is never usable as a credential itself. - The key is inactive or expired. Check
instance.is_valid— it'sis_active and not is_expired. A freshly created key defaults tois_active=Trueandexpires_at=None(never expires), so if it's failing, something explicitly set one of those.
request.tenant isn't set¶
TenantAPIKeyAuthentication/TenantAPIKeyAuth only attach request.tenant
if your concrete model has an attribute named exactly tenant
(hasattr(api_key, "tenant")). A differently-named relation
(organization, account, ...) won't be picked up automatically — read
request.auth.<your field name> instead, or rename the field. See
Multi-tenancy.
Scopes/IP restriction/rate limit aren't being enforced¶
For DRF, each policy is its own permission class, and none of them run
unless it's actually in permission_classes:
Forgetting one of them for a given view means that check simply doesn't run for that view — there's no global switch that turns policies on for every endpoint at once. See How the policies interact.
For Django Ninja, there's no permission-class system at all — every check
is an explicit if statement you write in the view body. If you didn't
write the check, it doesn't happen. See Django Ninja.
Also worth checking: is the field actually set on the key? A key with
allowed_ips=[] or rate_limit=None is unrestricted by design — that's
the backward-compatible default, not a bug.
Rate limiting seems to reset unexpectedly, or allows more than the configured limit¶
Two common causes:
- Multiple worker processes, default cache.
CacheRateLimitBackendwith Django's defaultLocMemCachecounts requests per process. If you're running gunicorn/uWSGI with more than one worker, each worker enforces the limit independently — arate_limit=100effectively becomes100 × number of workers. PointCACHES["default"](orTENANT_API_KEY_RATE_LIMIT_CACHE) at Memcached or a shared Redis to fix this. See Rate limiting: how the default backend counts requests. - Fixed-window boundary burst. A key can legitimately use up to ~2x its
limit across a window rollover (a full window's quota right before
:00, another full window's quota right after). This is a known characteristic of the fixed-window algorithm, not a bug — see the same section above.
TENANT_API_KEY_TRUSTED_PROXY_HEADER doesn't seem to work, or seems too permissive¶
If it's unset, get_client_ip() always reads REMOTE_ADDR — this is
intentional; the setting is opt-in. If it's set but the header isn't
present on the request, get_client_ip() falls back to REMOTE_ADDR
rather than erroring.
If it is set and IP restrictions seem bypassable, check whether your
proxy actually strips any client-supplied copy of that header before
adding its own. If it doesn't, a client can set
X-Forwarded-For: <whatever> themselves and have it trusted. See
IP restrictions: trusting a proxy header.
makemigrations wants to create a migration after upgrading¶
Expected, if the new release adds fields to AbstractTenantAPIKey (this
happens whenever a new policy or lifecycle field is added — see the
changelog).
Since it's abstract, Django doesn't ship a migration for it — your concrete
subclass's app needs one:
Every field added since 0.2.0 has a backward-compatible default, so existing rows and existing code keep working once the migration runs — you don't need to backfill anything by hand.
mypy/ruff failures when contributing¶
See Testing for the exact commands CI runs, and
CONTRIBUTING.md in the repository for the full contribution workflow.
Still stuck¶
Open a GitHub issue with a minimal reproduction: https://github.com/stackadnan/django-tenant-apikeys/issues. For anything security-related, use the private reporting form instead — see Security.