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."

A robot worker holding an API key and delivering a letter

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:

  1. User sends a web request
  2. Backend enqueues a "job"
  3. 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