Skip to content

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:

PieceWhere it livesYou write
POST /login — verify credentials, sign a JWTapp code — no generator emits itthe DTOs, the service method, and a thin handler
AuthnGuard — bearer JWT → principalalias over the shipped JwtStrategyone type line
AuthzGuard — principal → Abilityalias over the shipped AbilityGuardone type line + your policy

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:

crates/features/src/oauth/http/controller.rs (from the demo — you write this)
#[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):

Terminal window
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:

crates/features/src/authn/strategy.rs (from the demo)
use nest_rs::authn::JwtStrategy;
pub type AuthnStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs::authn::AuthnGuard<AuthnStrategy>;
crates/features/src/authz/guard.rs (from the demo)
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:

crates/features/src/users/http/controller.rs (from the demo)
#[controller(path = "/users")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersController {
#[inject]
svc: Arc<UsersService>,
}

And the feature’s HTTP module imports the authz adapter:

crates/features/src/users/http/module.rs (from the demo, abridged)
#[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.

Terminal window
nestrs run dev auth # issuer on :3001
nestrs run dev api # resource server on :3002
Terminal window
# 401 — no token
curl -si http://localhost:3002/users | head -1
# Log in, keep the bearer
TOKEN=$(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 org
curl -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.

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. DevTokenAudit refuses the boot wherever NESTRS_ENV does not positively read development or test, so a deployment that kept the dev-token route finds out at start-up rather than from a caller.