Add login and protect a route
The canonical first auth task, end to end — issue a JWT on POST /login, bind the two guards, curl the round-trip.
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 | app code — no generator emits it | the DTOs, the service method, and a thin handler |
AuthnGuard — 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”This slice is yours to write. nestrs g auth writes the two guards, the
Claims they name, the ability policy — and a POST /auth/dev-token route
that mints a token with no credential at all, so a scaffolded app can call its
own guarded routes from the first minute. That route refuses to boot outside
development and test, which is exactly why it is not the login: what counts as
a credential, where users live and what the token carries are domain decisions.
Delete crates/features/src/authn/http/ once the route below exists.
What keeps it out of production is NESTRS_ENV, and absence counts as
production. The route arms only when the variable positively reads development or
test — not “unless it reads production”. The variable is unset in a
fresh scaffold, in a container, and in most CI, so a default of
“development” would leave a token minter serving wherever nobody thought to
set it. just dev sets it; nothing else does. The check runs twice:
DevTokenAudit refuses the boot, so a deployment that kept the file finds out
at start-up, and the handler asks again per request, so registering the
controller from some other module buys nothing.
The parts underneath the real login are in the box, so the handler stays thin:
hash_password / verify_password / burn_verify (argon2, constant-time),
CredentialError (one opaque invalid credentials for every failure mode),
and JwtService::sign. LoginDto, AccessTokenDto and the grant_password
service method below are the three things you add.
The handler validates the body, delegates to the service, returns the token:
#[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:
use nest_rs::authn::JwtStrategy;
pub type AuthnStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs::authn::AuthnGuard<AuthnStrategy>;use nest_rs::authz::AbilityGuard;
use crate::authz::AuthzAbility;
pub type AuthzGuard = AbilityGuard<AuthzAbility>;AbilityGuard lives at nest_rs::authz, at the crate root — not under
http — because it answers every transport. nestrs g auth
writes both files, their modules, and the Claims they name.
Then protecting a controller is one attribute — AuthnGuard turns the
bearer token into a principal, AuthzGuard turns the principal into the
request’s Ability:
#[controller(path = "/users")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}And the feature’s HTTP module imports the authz adapter:
#[module( imports = [UsersModule, AuthzModule], 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.
What fails if you get it wrong
Section titled “What fails if you get it wrong”Each of these is a boot refusal, not a silent hole — the point of the design is that a half-wired login never serves an unprotected route.
- No signing key. With neither a secret nor a key pair set, the boot stops
on
no JWT key configured: set NESTRS_AUTHN__SECRET (HS256) or NESTRS_AUTHN__PUBLIC_KEY (EdDSA). - A secret under 32 bytes. HS256 draws its security from the secret’s
entropy, so a short one is refused at boot rather than used to mint forgeable
tokens:
NESTRS_AUTHN__SECRET must be at least 32 bytes for HS256. - The feature’s HTTP module does not import
AuthzModule. The guard bound on the controller is then unreachable through the access graph, and the boot names it. The route is never served unprotected. crates/features/src/authn/http/shipped to production.DevTokenAuditrefuses the boot whereverNESTRS_ENVdoes not positively readdevelopmentortest, so a deployment that kept the dev-token route finds out at start-up rather than from a caller.
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.
- Split deployment — the production split.