Files
What you'll learn
- Explain what a JWT is (a signed, readable, expiring claim about who is asking) and why the server can trust one it did not store
- Register JwtModule for the whole app and sign a payload with JwtService.signAsync
- Write a guard that reads a bearer token, verifies it, puts the payload on the request and answers 401 for anything else, and bind it with APP_GUARD
- Open individual routes with a @Public() decorator the guard reads through Reflector
Authentication
So far anyone who can reach the cats API can add a cat to the shelter, or delete one. The service has no idea who is asking. Authentication is the answer to that question: a visitor proves who they are once, with something only they know, and the API attaches an identity to every request they make afterwards. The mechanism this lesson builds is the one most APIs use: a username and password exchanged for a token, and the token presented on every later request in a header.
What a token is
The API could keep a table of sessions and hand each visitor a random id. A JSON Web Token avoids the table. It is three base64url segments joined by dots: a header naming the algorithm, a payload of claims ({ "sub": 7, "username": "ada", "iat": 1788765411, "exp": 1788765471 }), and a signature computed from the first two with a secret only the server knows. Whoever holds the token can read the payload; base64url is not encryption. What they cannot do is change it, because the signature would no longer match, and they cannot make one up, because they lack the secret. So the server trusts the payload without storing anything: verifying the signature is the whole check. sub is the subject (the user's id), iat when it was issued, exp when it stops being valid, all added by the signer.
Signing and verifying with @nestjs/jwt
JwtModule.register() configures one signer for the app. global: true makes JwtService injectable from any module without importing JwtModule again; secret is the signing key; signOptions.expiresIn gives every token a lifetime, '60s', '1h', '7d':
JwtModule.register({
global: true,
secret: 'a long random string from configuration',
signOptions: { expiresIn: '1h' },
})
JwtService then signs and verifies. Both have promise-returning forms, which is what a handler awaits:
const token = await this.jwtService.signAsync({ sub: 7, username: 'ada' });
const payload = await this.jwtService.verifyAsync(token);
verifyAsync returns the payload when the signature matches and the token has not expired. Otherwise it throws, and the error says why: JsonWebTokenError: invalid signature for a token the secret never produced, TokenExpiredError: jwt expired once exp is in the past, JsonWebTokenError: jwt malformed for something that is not three segments. An API does not repeat those reasons to the client; it answers 401 Unauthorized and keeps the detail for its logs.
Where the token travels
The client sends it in the Authorization header, with a scheme that says what kind of credential follows: Authorization: Bearer eyJhbGci.... The scheme matters. The same header can carry Basic dXNlcjpwYXNz (a base64 username and password), and a guard that accepts any scheme would treat a Basic credential as a token. Reading the header is a split on the space:
const [scheme, credential] = request.headers.authorization?.split(' ') ?? [];
and only scheme === 'Bearer' yields a token.
The guard, and where it sits
Basics lesson 8 built guards that answer one question, may this request proceed, and throw or return false when not. Authentication is that question. A guard that verifies the token runs after middleware and before pipes and the handler, so a handler never sees an unauthenticated request. The verified payload goes on the request object, request['user'] = payload, where a handler reads it with @Request() or, better, a custom decorator (Basics lesson 10).
Bound with @UseGuards() on each controller, such a guard is easy to forget on the next one. APP_GUARD binds it once for every route in the app, from any module's providers:
providers: [{ provide: APP_GUARD, useClass: AuthGuard }]
Then every route requires a token, including the one that issues tokens. Nobody could ever sign in. The docs' answer is metadata: a @Public() decorator is SetMetadata('isPublic', true), and the guard reads it with Reflector:
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
getAllAndOverride looks at the handler first, then its class, so a route can be opened individually or a whole controller at once.
Your task
The shelter's staff sign in; visitors browse.
- In
auth/auth.module.ts, register the JWT module for the whole app with the secret fromconstants.tsand tokens that expire after 60 seconds, and bindAuthGuardto every route. - In
auth/auth.service.ts, once the password checks out, answer with a token whose payload carries the user's id assuband the username. - In
auth/auth.guard.ts, let a@Public()route through; otherwise requireAuthorization: Bearer <token>, verify it, put the payload onrequest.user, and answer 401 for a missing header, another scheme, or a token that does not verify. - Mark the login route public, and in
cats/cats.controller.tskeep listing and reading a cat public while creating and deleting need a token.
Do step 2 before step 1 and press Run: Nest refuses to start, because AuthService asks for a JwtService that no module provides yet. Read the message, then register the module.
When it fails
Nest can't resolve dependencies of the AuthService (UsersService, ?):JwtServiceis not available.JwtModule.register()is missing from the imports, or it lacksglobal: trueand the module that needs it does not import it.- Every route is 401, including
POST /auth/login: the guard is global and nothing is marked public, or the guard never reads the metadata; the sign-in route is the first to open. GET /auth/profileis 200 withBasicin the header: the guard takes whatever follows the first space. Check the scheme.- A forged token gets a 500 instead of a 401:
verifyAsyncthrew and nothing caught it. Wrap it and turn any failure intoUnauthorizedException. request.useris undefined in the handler: the guard verified the token but never stored the payload, or it stored it under another name.
Remember
- A JWT is readable, signed and expiring; the signature is what the server checks, so it stores nothing.
JwtModule.register({ global, secret, signOptions }), thensignAsyncto issue andverifyAsyncto check; both throw rather than return on failure.- The client sends
Authorization: Bearer <token>; the scheme is part of the check. APP_GUARDguards every route;@Public()plusReflectoropens the ones that must stay open.
Stuck? Show a hint
auth.module.ts: imports JwtModule.register({ global: true, secret: jwtConstants.secret, signOptions: { expiresIn: '60s' } }) and a provider { provide: APP_GUARD, useClass: AuthGuard }. auth.service.ts: this.jwtService.signAsync({ sub: user.userId, username: user.username }). auth.guard.ts: reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]); request.headers.authorization?.split(' ') gives [scheme, token]; jwtService.verifyAsync(token) inside try/catch, and request['user'] = payload. Controllers: @Public() on login and on the two GET cats routes.
Press Run tests to start the app. Its log appears here.Graded endpoints
Listing the shelter is public, so the guard lets it through with no Authorization header
Changing the roster is not public; the guard answers Nest's 401 before the handler runs
The right password earns a token; its first segment is the base64url JWT header, the same for every HS256 token. The token is kept for the requests below
The password does not match, so UnauthorizedException, and no hint about which half was wrong
No such user: the same 401, the same body
The guard verified the bearer token and put its payload on request.user; iat and exp were added by the signer
Three segments, but a signature the secret never produced: verifyAsync throws and the guard answers 401
A valid token sent as Basic instead of Bearer is not a bearer token; the header's scheme is part of the contract
No Authorization header at all
Signed in, john may add a cat
Reading a cat is public too
Deleting is staff only
Signed in, john may remove a cat
Tom is gone