Add login and protect a route
The first auth task is always the same: a POST /login that returns a
token, and routes that require it. This page walks that happy path end
to end on the Publish exemplar. Everything here is real code from the
repo — follow along, then adapt the policy to your domain.
Three pieces, two of which you already have:
| Piece | Where it lives | You write |
|---|---|---|
POST /login — verify credentials, sign a JWT | crates/features/src/oauth/ (grant_password) | a thin handler |
AuthGuard — bearer JWT → principal | alias over the shipped JwtStrategy | one type line |
AuthzGuard — principal → Ability | alias over the shipped AbilityGuard | one type line + your policy |
1. Issue a token on POST /login
Section titled “1. Issue a token on POST /login”The handler is thin: validate the body, delegate to the service, return
the token. Credential verification (UsersService::authenticate, argon2)
and signing (JwtService) are already in the box:
#[post("/login")]#[use_guards(ThrottlerGuard)]#[meta(Throttle::per_minute(10))]async fn login(&self, body: Valid<Json<LoginDto>>) -> Result<Json<AccessTokenDto>> { let input = body.into_inner(); Ok(Json( self.svc.grant_password(&input.email, &input.password).await?, ))}grant_password authenticates the email/password pair and signs the
claims — invalid credentials come back as one opaque error (no
“user exists but wrong password” oracle). Note the route binds a
throttler, not an auth guard: login is the one route that must accept
anonymous callers, and rate-limiting is its protection.
For signing to work the app needs a key. The simplest single-app setup is a symmetric secret (32 bytes minimum):
export NESTRS_AUTHN__SECRET="an-actual-random-32-byte-minimum-secret"2. Bind the two guards on whatever must be protected
Section titled “2. Bind the two guards on whatever must be protected”The two guards are project-local aliases written once:
pub type AuthGuard = nest_rs_authn::AuthGuard<AppJwtStrategy>;pub type AuthzGuard = AbilityGuard<AppAbility>;Then protecting a controller is one attribute — AuthGuard turns the
bearer token into a principal, AuthzGuard turns the principal into the
request’s Ability:
#[controller(path = "/users")]#[use_guards(AuthGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}And the feature’s HTTP module imports the authz adapter:
#[module(imports = [UsersModule, AuthzHttpModule], providers = [UsersController])]pub struct UsersHttpModule;That import is load-bearing: forget it and the app fails at boot naming the unreachable guard — it never silently serves the route unprotected.
3. Curl the round-trip
Section titled “3. Curl the round-trip”nestrs run dev auth # issuer on :3001nestrs run dev api # resource server on :3002# 401 — no tokencurl -si http://localhost:3002/users | head -1
# Log in, keep the bearerTOKEN=$(curl -sX POST http://localhost:3001/login \ -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"hunter2"}' \ | jq -r .access_token)
# 200 — and every row is already filtered to Ada's orgcurl -s http://localhost:3002/users \ -H "Authorization: Bearer $TOKEN" | jq '.[].name'That last call gets more than a yes/no gate: with both guards bound, the
ambient Ability row-filters every query the service runs and masks the
response body — see
What you get once both are mounted.
Going further
Section titled “Going further”- Authentication — the shipped JWT strategy, then writing your own.
- Password flow — what
grant_passworddoes under the hood. - Policies — replace the demo policy with your domain’s rules.
- Issuer and resource server — the production split.