Security
Security in nestrs splits into two concerns wired by two composable layers. Authentication establishes who the caller is. Authorization declares what they may do, then enforces it on every read, every by-id write, and every response body before it leaves the process. Bind the two guards on the controller and the framework does the rest — there is no per-handler checklist.
Here for the first auth task?
Add login and protect a route walks the whole
happy path — issue a JWT on POST /login, bind the two guards, curl the
round-trip — before any of the reference below.
The authn layer leans on jsonwebtoken,
argon2, and the
oauth2 crate. The authz layer is the
framework’s own CASL-style engine, with row-level filtering bridged into
SeaORM.
Two concerns, two layers
Section titled “Two concerns, two layers”| Concern | Layer | Who answers it |
|---|---|---|
| Who is calling? | Authentication | A Strategy turns the request into a principal (or returns a 401 / OAuth redirect) |
| What may they do? | Authorization | An Ability declares allowed actions; the framework applies it to queries, by-id loads, and response bodies |
Each layer ships its own guard. Bind them in order — the authn guard
runs first and attaches the principal; the authz guard reads it and
installs the ambient Ability.
#[controller(path = "/users")]#[use_guards(AuthGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}That’s the whole opt-in surface for HTTP. GraphQL and WebSockets get matching bridges that re-establish the ambient ability at the dispatch point — see Per-transport bridges.
What you get once both are mounted
Section titled “What you get once both are mounted”- Every read is filtered. The
Repogateway every service uses joins the ambient ability’scondition_forinto the SQL — a member queryingUsers::find()getsWHERE org_id = $caller_org_idappended. - Every by-id write checks access.
Bind<S, A>callsService::access(id); missing rows return404, denied rows return403— see By-id binding. - Every response is masked. The
Authorizeshaper runs after a 2xx handler: fields the caller cannot read are stripped from the body, andretain_wire_keysdrops anything outside the wire DTO so an unrestricted grant cannot leak an unexposed column.
The wiring sketch for an app that serves all three transports:
#[module( imports = [ DatabaseModule::for_root(None), HttpModule::for_root(None), GraphqlModule::for_root(None), AuthnModule, AuthzHttpModule, AuthzGraphqlModule, AuthzWsModule, UsersHttpModule, UsersGraphqlModule, UsersWsModule, ],)]pub struct ApiModule;Where each piece lives
Section titled “Where each piece lives”Directorysecurity/
Directoryauthentication/
- index.mdx —
Strategy,AuthGuard<S>, reading the principal back withCtx<P> - jwt.mdx —
JwtStrategy,JwtService, HS vs EdDSA keys, claims - oauth2.mdx — authorization-code flow with PKCE
- password.mdx — Argon2id hashing and
burn_verify - issuer-and-resource-server.mdx — the split-deploy pattern
- index.mdx —
Directoryauthorization/
- index.mdx —
Ability,AppAbility, where it runs - policies.mdx —
Subject,Action,RuleSpec, predicates, fields - row-level-filtering.mdx —
condition_for,Repo::scoped - by-id-binding.mdx —
Bind<S, A>, theAccessoutcome - response-masking.mdx —
Authorize,mask,WireModelDefaults - per-transport-bridges.mdx — HTTP / GraphQL / WS / MCP
- index.mdx —
- threat-model.mdx — what each layer catches, honestly (read before production, not needed for your first feature)
Going further
Section titled “Going further”- Threat model — what these layers cover and what they don’t (explanation — read before going to production, not needed for your first feature).
- Guards — the primitive every security layer builds on.
- Throttler — rate limiting as a guard, composes with
AuthGuard.