1. "Why Does the Backend Not Know Who I Am?" (The Problem Begins)
OAuth2, JWT, session authentication… there are countless ways to authenticate, and for most projects they’re sufficient. I used them all.
- When I added OAuth2 to a custom email client or Custom GPTs in ChatGPT, it felt like a real user experience.
- A single‑page Django app works best with session authentication.
- In a front‑end/back‑end split, JWT was the cleanest solution.
But there’s a point where this combination falls short: asynchronous work (Celery). When a user clicks a button, the request isn’t handled directly by the back‑end; it’s handed off to a distant AI compute server or a worker. The worker then asks:
"I received the request, but on whose behalf am I acting? There is no
request.user."

2. The Pain of "Backend ↔ Backend" + "Async Worker (Celery)"
The turning point for me was the architecture where backend services talk to each other and a Celery worker sits in the middle.
The flow isn’t:
- User sends a web request
- Backend enqueues a "job"
- Celery worker consumes the job and makes an asynchronous call to some compute service
The hardest part of this chain is that the worker:
- Has no
request.user - Has no session (it isn’t a browser)
- JWT handling becomes ambiguous – who issues the token, where is it stored, how is it passed?
- Cannot use OAuth2 because that flow assumes a user interaction
When JWT and sessions prove insufficient, the question reduces to a single one:
"How does the compute server know which tenant/user this job belongs to?"
3. Workers Need Identification Before Authentication
In a normal web request, authentication = login = a user. A worker, however, is an application process that consumes CPU – it needs to be identified first. Authentication can be handled later with HMAC or a secret key.
- The job must run on Customer A’s data
- The result must be stored under Customer A’s resources
- Billing, permissions, and quota must be deducted for Customer A
Trying to force JWT or session logic into this scenario complicates the design of token issuance, storage, transmission, expiration, and rotation. More importantly, it feels fundamentally wrong to “issue a JWT for a user who isn’t actively using a browser”. The discomfort was enough to discard that approach.
4. Solution: API Key Was Simple and Powerful
Enter API Key. It solved the problem in one stroke.
- The worker adds one header to authenticate and identify itself.
- The receiving service maps that key instantly to a specific user/customer.
- Rotating or revoking keys becomes straightforward.
For example, the back‑end would call the compute service like this:
POST /v1/ai/jobs
Authorization: Api-Key <KEY>
Content-Type: application/json
{ "job_id": "...", "payload": {...} }
The worker doesn’t need a request.user; the downstream service uses the API key to resolve the user.
5. Game‑Changer: Tying API Keys to Users Simplifies Operations
What really impressed me was the ability to bind the key to a user model. The generic rest_framework_api_key library provides keys, but my use‑case required a key ↔ user relationship.
By subclassing AbstractAPIKey into CustomAPIKey and adding a foreign key to AUTH_USER_MODEL, the integration became seamless.
from django.conf import settings
from rest_framework_api_key.models import AbstractAPIKey
from django.db import models
class CustomAPIKey(AbstractAPIKey):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="api_keys"
)
is_test = models.BooleanField(default=False) # stage / test keys
Now authentication transformed into a full‑featured operational lever.
6. Benefits of Auto‑Issuing a Key Per User
Generating a key automatically when a user signs up unlocked several conveniences.
1) Easy validity management
- Look up, deactivate, or delete a specific user’s keys with a single query.
- When a user account is deleted, cascade deletion cleans up associated keys.
2) Straightforward key rotation
- If a key is suspected of being compromised, issue a new one and retire the old.
- Supporting multiple keys enables zero‑downtime replacement (new key rolls out, old key is retired later).
3) Billing, quota, and permission tied to the user
- Charge or limit per user, not per key.
- No need to infer “whose request is this?” each time.
4) Multiple keys per user
The is_test flag proves particularly useful here. Because the relationship is a FK rather than OneToOne, a single user can own several keys for different purposes.
* A staging key and a production key can coexist.
* Development and production flows stay isolated.
* Logging can clearly separate test traffic from real traffic.
7. Authentication Choices Are About Context, Not Hierarchy
In summary, the optimal combination I keep using looks like this:
- OAuth2 – best for external services where user consent is essential.
- Session authentication – ideal for a pure Django app; fast and simple.
- JWT – fits front‑end/back‑end separation, mobile, SPA, etc.
- API Key – excels in back‑end‑to‑back‑end, automation, workers, and batch jobs where there is no user interaction.
When a Celery worker enters the picture, trying to force a “login‑based” auth model only adds complexity. API keys become the clean escape hatch.
8. Closing Thoughts
Humans (browsers/apps) are naturally handled with sessions, JWTs, or OAuth2. Workers, however, are processes that need to be identified. I switched to API keys not because of a grand security theory, but because it was the simplest solution for that specific gap. By tying the key to a user, key management turned from a nuisance into a powerful operational lever.
Do you use API keys often? The pattern I described is just one aspect of their convenience, but I hope this experience gives you a useful perspective.
Related Posts
There are no comments.