In Django Ninja, with auth= set on the NinjaAPI, a Router and an operation, which applies, and how is one operation made public?
answer
- most specific setting wins
- replaced, not merged
- mount argument beats constructor
- None means no authentication
basics
~10 sThe most specific auth= replaces the others: operation, then the add_router() mount, then Router(auth=), then a parent router, then NinjaAPI(auth=). Settings are never merged. auth=None on an operation or router makes it public.
solid answer
~30 sNinja resolves `auth=` per operation by taking the **most specific** setting and ignoring the rest: the operation's own `auth=`, else the `auth=` given to `api.add_router()` for that mount, else the `Router(auth=...)` constructor, else a parent router's auth for nested routers, else `NinjaAPI(auth=...)`. Nothing is combined - a router with `auth=PartnerKey()` under `NinjaAPI(auth=django_auth)` accepts only partner keys. A list such as `auth=[PartnerKey(), django_auth]` means *either* authenticator, tried in order, never both. To make one operation public, pass `auth=None` on its decorator; `Router(auth=None)` opts a whole router out of API-level auth. With no `auth=` anywhere, every operation is public.
code
python · 19 linesfrom ninja import NinjaAPI, Router
from ninja.security import django_auth
api = NinjaAPI(auth=django_auth) # staff session by default
partners = Router(auth=PartnerKey()) # replaces django_auth for this router
@partners.get("/status", auth=None) # public probe
def status(request):
return {"ok": True}
@partners.get("/orders/{order_id}", auth=[PartnerKey(), django_auth]) # partner OR staff
def order_detail(request, order_id: int):
return {"caller": str(request.auth), "order": order_id}
api.add_router("/partners/", partners)go deeper
Recall that auth= can be set on the API, a router or an operation, and that auth=None makes an operation public.
Explain the resolution order, that the winner replaces the others, and that a list is an either-or chain.
Review an API for accidental exposure: public-by-default operations, routers that silently drop staff access, and lists mistaken for all-of requirements.
Set the convention that the API is closed by default and every public operation carries an explicit auth=None, so exposure is a visible decision in review.
## Three places to set auth= In **Django Ninja** the `auth=` argument exists at three levels: - **`NinjaAPI(auth=...)`** - the default for every operation in that API; - **`Router(auth=...)`**, or **`api.add_router(prefix, router, auth=...)`** - the default for every operation on that router; - **the operation decorator**, such as `@router.post("/orders", auth=...)` - that operation only. Each accepts one authenticator, a list of them, or `None`. Leaving it out means "not set here", which is different from `None`. ## Which one applies For each operation Ninja walks from the most specific level outwards and stops at the first one that was set: 1. the operation's own `auth=`, including an explicit `None`; 2. the `auth=` passed to `add_router()` for this particular mount; 3. the `auth=` passed to the `Router` constructor; 4. the auth inherited from a parent router, when routers are nested; 5. the `NinjaAPI`'s `auth=`. The winner **replaces** everything below it. Ninja does not merge levels or require several of them to pass. A common surprise is a router mounted as `Router(auth=PartnerKey())` inside `NinjaAPI(auth=django_auth)`: logged-in staff lose access to it, because the router's setting has replaced the API's. | setup | effective auth for a router operation without its own `auth=` | |---|---| | `NinjaAPI(auth=A)`, `Router()` | `A` | | `NinjaAPI(auth=A)`, `Router(auth=B)` | `B` only | | `NinjaAPI(auth=A)`, `Router(auth=B)`, mounted with `add_router(..., auth=C)` | `C` only | | `NinjaAPI(auth=A)`, `Router(auth=None)` | none - public | | no `auth=` anywhere | none - public | ## Lists mean "either" `auth=[PartnerKey(), django_auth]` does not demand both credentials. Ninja tries the authenticators **in order**; the first truthy result becomes `request.auth` and the others are skipped. If one of them raises - a failed CSRF check in a cookie-based authenticator raises a 403 - the chain stops there. Requiring two credentials at once takes a single custom authenticator that checks both, or a check inside the view. ## Making something public - **One operation**: `@api.get("/health", auth=None)`. The explicit `None` is "set", so the inheritance walk stops and no authenticator runs. - **A whole router**: `Router(auth=None)`, for a public catalogue inside an otherwise protected API. - **An empty list is not the same thing**: Ninja's documented off-switch is `None`; do not rely on `auth=[]`. Because public is the default when nothing is set, an API that should be closed by default needs `NinjaAPI(auth=...)`, and every public endpoint should then be an explicit `auth=None` a reviewer can see. ## A partner layout A typical project combines staff and partner access: - `api = NinjaAPI(auth=django_auth)` - internal dashboard endpoints use the staff session; - `partners = Router(auth=PartnerKey())` mounted at `/partners/` - partner endpoints accept only keys; - `@partners.get("/status", auth=None)` - a public status probe; - `@partners.get("/orders/{order_id}", auth=[PartnerKey(), django_auth])` - partners *or* support staff can read an order. The last operation's view has to handle both kinds of `request.auth` - a `Partner` or a Django `User` - before it decides what the caller may see. ## Authentication is not authorisation `auth=` answers **who is calling**; it does not decide what that caller may do. Once `request.auth` is set, permission checks are ordinary code: - inline in the view - `if not request.auth.is_staff: raise HttpError(403, "Permission denied")`; - a decorator placed **below** the operation decorator, which runs after authentication and validation, so `request.auth` is available; - `router.add_decorator(...)` or `api.add_decorator(...)` to apply such a decorator to every operation; the default `"operation"` mode runs after authentication, while `"view"` mode runs before it, when `request.auth` does not exist yet. For the common session cases Ninja ships `django_auth_superuser` and `django_auth_is_staff`, which authenticate only superusers, or staff and superusers. For partners, the check reads the `Partner` object's own fields, such as whether it owns the order being requested. ## Mistakes interviewers probe - Assuming router auth adds to API auth rather than replacing it. - Assuming a list means all authenticators must pass. - Forgetting that `add_router(..., auth=...)` overrides the router's own constructor argument for that mount. - Leaving `NinjaAPI` without `auth=` and relying on every developer to remember it on each operation. - Treating `auth=None` as "inherit": it means no authentication at all.
- Does a nested router inherit its parent router's auth in Django Ninja?Yes. When a router is added to another router, its operations inherit the parent's auth unless the child router or the operation sets its own. The walk still stops at the most specific setting, so a child `Router(auth=None)` makes its operations public even under a protected parent.
- How would you require both a partner key and a staff session on one operation?Not with a list, which means either. Write one authenticator - for example an `APIKeyHeader` subclass whose `authenticate()` also checks `request.user.is_authenticated` and `is_staff` - and return an object carrying both identities, or authenticate one way and check the other inside the view.
saying these in an interview costs you the question
- Router-level auth is added on top of the API-level auth.
- auth=[A(), B()] requires a request to pass both authenticators.
- auth=None on an operation means it inherits the router's auth.
- Operations are protected by default even when no auth= is set.
- The Router constructor's auth beats the auth passed to add_router().