Security
Two concerns, two layers:
- Authentication — who the caller is. A
Strategyturns a request into a principal (or returns a challenge — a 401 / an OAuth redirect). - Authorization — what they may do. An
Abilitydeclares the allowed actions on subjects; a guard builds it from the principal; the framework applies it everywhere downstream (queries, by-id loads, response bodies).
Once both are mounted, every read goes through a filtered Repo,
every by-id write fails-closed on a denied row, and every response
is masked before it leaves the process. There is no per-handler
checklist.
Two-line wiring per transport
Section titled “Two-line wiring per transport”#[module( imports = [ DatabaseModule::for_root(None), AuthnCoreModule, AuthzHttpModule, // ← HTTP authz AuthzGraphqlModule, // ← GraphQL authz UsersHttpModule, UsersGraphqlModule, // ... ],)]pub struct AppModule;#[controller(path = "/users")]#[use_guards(AuthGuard, AppAbilityGuard)] // ← bind both layerspub struct UsersController { #[inject] svc: Arc<UsersService>,}That’s the whole opt-in surface. From there, the framework does the rest.
Authentication — JWT in 3 lines
Section titled “Authentication — JWT in 3 lines”A resource-server app declares a single alias:
pub type AuthGuard = nestrs_authn::AuthGuard<JwtStrategy<Claims>>;JwtStrategy<C> is provided by the framework; it verifies an
Authorization: Bearer <token> against the configured public key and
puts Claims on the request. A 401 is returned automatically on a
missing or invalid token.
A handler reads the principal with Ctx<Claims>:
async fn create(&self, auth: Ctx<Claims>, body: Valid<Json<CreateUserInput>>) -> Result<Json<User>>{ Ok(Json( self.svc.create_in_org(body.into_inner(), auth.org_id).await?, ))}Authorization — define the policy once
Section titled “Authorization — define the policy once”impl Ability<Claims> for AppAbility { fn build(claims: &Claims) -> Ability<Self> { let mut a = Ability::new(); match claims.role.as_str() { "admin" => { a.can(Action::Manage, Subject::All); } _ => { a.can(Action::Read, Subject::Entity::<UserEntity>()) .when(|claims, row: &UserEntity::Model| row.org_id == claims.org_id); a.can(Action::Update, Subject::Entity::<UserEntity>()) .when(|claims, row: &UserEntity::Model| row.id == claims.sub); } } a }}AppAbility is the single source of truth: it covers HTTP, GraphQL, and
WebSockets through the three transport bridges (AuthzHttpModule,
AuthzGraphqlModule, AuthzWsModule).
Three layers of enforcement — for free
Section titled “Three layers of enforcement — for free”1. Row-level filtering on every read
Section titled “1. Row-level filtering on every read”Repo::<Users>::scoped(Action::Read).filter(...).all(&conn) joins the
ambient ability’s condition_for(Read) into the SQL. A user with role
user querying Users::find() automatically gets
WHERE org_id = $caller_org_id appended.
The CrudService::list, page, access methods all go through
Repo::scoped(...) — controllers cannot bypass this.
2. By-id loads check access
Section titled “2. By-id loads check access”#[get("/:id")]async fn get(&self, user: Bind<UsersService, Read>) -> Json<User> { Json(User::from(&*user))}Bind<S, A> calls S::access(id) which:
- loads through
Repo::<S::Entity>::scoped(A); - returns
Err(404)if the row does not exist within the caller’s scope; - returns
Err(403)if the row exists but is outside scope (denied).
A 404 vs 403 distinction here is a policy decision, not an implementation detail.
3. Response masking before the body leaves
Section titled “3. Response masking before the body leaves”After a successful handler returning Json<T>, the Authorize shaper:
- parses the JSON body;
- reconstructs the
Model(filling#[expose(skip)]columns fromWireModelDefaults); - runs
Ability::maskon the model — fields the caller cannot read becomenull; retain_wire_keysstrips back any field that was not in the original wire DTO. An unrestricted field grant cannot leak a#[expose(skip)]column (likepassword_hash).
If the body cannot be reconciled with Model, the shaper fails
closed with a 500 — never unmasked data.
Run it
Section titled “Run it”# Mint a token for Ada (role: user, org: A)$ TOKEN=$(curl -sX POST http://localhost:3002/token \ -d 'grant_type=password&username=ada@example.com&password=password' \ | jq -r .access_token)
# Ada can see her org's users$ curl -s http://localhost:3003/users \ -H "Authorization: Bearer $TOKEN" | jq '.[].name'"Ada Lovelace""Bob Engineer" # same org
# Ada cannot reach a user in another org$ curl -s -o /dev/null -w "%{http_code}\n" \ http://localhost:3003/users/0193c1ee-...other-org \ -H "Authorization: Bearer $TOKEN"403
# password_hash is never in the body — it's #[expose(skip)]$ curl -s http://localhost:3003/users -H "Authorization: Bearer $TOKEN" \ | jq '.[0] | keys'[ "email", "id", "name", "org_id" ]Split issuer and resource server
Section titled “Split issuer and resource server”apps/auth issues tokens (signs with the private EdDSA key);
apps/api verifies them (holds only the public key). They share
the same Claims type via the identity crate, never RPC each other,
and the resource server cannot mint a token even if it wanted to.
just dev auth # token issuer on :3002just dev api # resource server on :3003Going further
Section titled “Going further”Per-route guards
Section titled “Per-route guards”When one route needs stricter rules than the controller default:
#[delete("/:id")]#[use_guards(AdminGuard)]async fn delete(&self, ...) { /* ... */ }#[meta] + Reflector for declarative policies
Section titled “#[meta] + Reflector for declarative policies”#[get("/admin")]#[use_guards(RolesGuard)]#[meta(Roles(["admin", "auditor"]))]async fn admin(&self) -> &'static str { "ok" }The guard reads Reflector::new(req).get::<Roles>() and decides.
Reference
Section titled “Reference”crates/features/src/authn/—Strategy,Outcome, the JWT alias.crates/features/src/authz/—AppAbility, the three transport modules.crates/features/src/oauth/— the OAuth2 authorization-code flow.crates/nestrs-authn/— the trait, the strategies, JWT signing/verifying.crates/nestrs-authz/—Ability,mask,condition_for, the shaper.