NestJS course Β· Module 4: Authentication

JWT Tokens - Roman passes

6 min read
In this lesson6

A legionary gave the password at the gate and was let in. An hour later he returns for supplies - and what, gives the password again? And at the third gate? Checking a password at every step means the server must reach into the database for the user on every single request. Hundreds of times a minute, for the same thing.

In such cases the Romans issued a pass: a document listing the bearer's privileges, carrying a seal any sentry can recognise. The sentry does not run to the archive - he looks at the seal and knows the document is genuine. On the web this pass is called a JWT, a JSON Web Token.

The pass's three parts

A JWT looks like a string of characters split by two dots. Those dots are not accidental - they divide it into three parts, always in the same order:

1eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJyb2xlIjoiY2VudHVyaW9uIn0.dBjftJeZ4CVP
2   <-- Header -->      <----- Payload ----->                  <- Signature ->

The Header says which algorithm signed the document. The Payload is the pass's content - the data about the legionary, called claims: his identifier, role, expiry time. The Signature is the seal: the result of signing the first two parts with the server's secret key.

Now the most important and most often confused point: the payload is encoded, not encrypted. Anyone who intercepts the token can read its contents - it is plain Base64, not a secret. The seal does not hide the data, it proves nobody swapped it, because without the secret key it cannot be forged. There is one practical conclusion: never put a password or sensitive data in the payload.

Configuration - issuing passes

We add the ability to issue passes through a module:

1JwtModule.registerAsync({
2  imports: [ConfigModule],
3  inject: [ConfigService],
4  useFactory: (configService: ConfigService) => ({
5    secret: configService.get('JWT_SECRET'),
6    signOptions: {
7      expiresIn: '24h',
8      issuer: 'LegionaryFleet',
9    },
10  }),
11});

secret is the secret key with which the server seals tokens and later verifies them. We read it through ConfigService from an environment variable, written in the .env file as JWT_SECRET=super-secret-key - name, equals sign, value, no quotes and no spaces. A key written into the code would land in the repository and from there in someone else's hands; whoever holds the key can issue themselves a centurion's pass.

We use registerAsync rather than plain register because the key's value is not known while the code is being written - it has to be read from configuration first. Hence useFactory: a function NestJS calls once ConfigService is available.

In signOptions we describe the pass itself. expiresIn: '24h' is its lifetime - after a day the document loses validity and the legionary must log in again. That limit is deliberate: should the token leak, the thief has only that much time. issuer is the issuing party, recorded in the payload.

The shape of a pass

It is worth describing with a type exactly what we put into the payload:

1export interface JwtPayload {
2  sub: number;
3  username: string;
4  email: string;
5  role: string;
6  iat: number;
7  exp: number;
8}

Three of these names are abbreviations fixed by the JWT standard, which is why they look odd. sub is subject - the identifier of whoever the pass concerns, that is simply the user's id. iat is issued at, the moment of issue. exp is expiration, the moment of expiry - it is what the time check watches. The iat and exp fields are added by the library itself; username, email and role are your own claims.

Issuing the pass at login

When the password matches, the service signs a token and hands it to the client:

1async login(user: User) {
2  const payload = {
3    sub: user.id,
4    username: user.username,
5    role: user.role,
6  };
7
8  return {
9    access_token: this.jwtService.sign(payload),
10  };
11}

jwtService.sign() takes your object, adds iat and exp according to signOptions, and seals the whole with the secret key. The returned string is a finished pass. Note what is absent here: the server remembers nothing. No session row is created - all the knowledge about the legionary travels inside the token itself. That is the difference between a pass and a guest list at the gate.

So the full cycle looks like this: the client sends a username and password, the server verifies them in the database, the server generates a token, the client attaches it to every subsequent request in the Authorization header, and the server decodes the token and authorises the request - without reaching into the database for a password.

The pass sentry

Who actually checks the seal on every request? A strategy does - a class in which you describe where to take the token from and how to verify it. You will meet the full system of strategies in the next lesson; for now this one, for passes, is enough:

1@Injectable()
2export class JwtStrategy extends PassportStrategy(Strategy) {
3  constructor(private configService: ConfigService) {
4    super({
5      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
6      ignoreExpiration: false,
7      secretOrKey: configService.get('JWT_SECRET'),
8    });
9  }
10
11  async validate(payload: JwtPayload) {
12    return { userId: payload.sub, username: payload.username, role: payload.role };
13  }
14}

The three options in super() describe how the sentry reads the document. jwtFromRequest says where to take the token from - ExtractJwt.fromAuthHeaderAsBearerToken() pulls it out of the Authorization: Bearer <token> header, the most common place. secretOrKey is the same key the token was signed with; without it the seal cannot be checked.

ignoreExpiration: false tends to confuse through its double negative, so let's read it plainly: do not ignore expiry, that is, check exp and reject stale tokens. This is the setting you want. Setting it to true would mean a year-old pass still opens the gates.

The validate() method receives an already verified payload - the seal was checked, the expiry date too, before your code ran. The returned object lands in request.user and becomes available in the controller. Note that there is no database query here: all the needed data arrived inside the token, and that is exactly the saving for which we introduced passes.

Summary

The legionary carries a pass and does not give the password at every gate:

  • a JWT consists of three parts in a fixed order: Header, Payload, Signature,
  • the payload is encoded, not encrypted - anyone can read it, so put no passwords there,
  • the signature does not hide the data, it proves nobody swapped it,
  • JwtModule.registerAsync with useFactory lets you take secret from ConfigService; the key lives in .env, never in code,
  • signOptions.expiresIn sets the token's lifetime, issuer the issuing party,
  • standard claims: sub (user id), iat (issued at), exp (expires),
  • jwtService.sign(payload) issues the pass, and the server remembers nothing,
  • in JwtStrategy: ExtractJwt.fromAuthHeaderAsBearerToken() reads the token from the Bearer header, ignoreExpiration: false rejects stale ones, and validate() receives the payload already verified.

In the next lesson you will meet Passport.js as a whole system of strategies - you will see that the pass sentry is only one of many. For now remember: a JWT is a sealed pass - anyone can read what it says, but nobody can forge it without the secret key.

Code for this lesson: src/auth/jwt-config.ts
1// JWT Tokens - Roman Passes
2// Each pass consists of 3 parts: Header, Payload, Signature
3import { Module } from '@nestjs/common';
4import { JwtModule, JwtService } from '@nestjs/jwt';
5import { ConfigModule, ConfigService } from '@nestjs/config';
6import { Injectable } from '@nestjs/common';
7
8// ===========================================
9// 1. JWT Module configuration
10// ===========================================
11
12@Module({
13  imports: [
14    JwtModule.registerAsync({
15      imports: [ConfigModule],
16      useFactory: async (configService: ConfigService) => ({
17        // Secret for signing tokens
18        secret: configService.get<string>('JWT_SECRET') || 'spqr-secret-key',
19        signOptions: {
20          expiresIn: '24h',      // Token valid for 24 hours
21          issuer: 'imperium-romanum', // Token issuer
22          algorithm: 'HS256',    // Signing algorithm
23        },
24      }),
25      inject: [ConfigService],
26    }),
27  ],
28  exports: [JwtModule],
29})
30export class JwtConfigModule {}
31
32// ===========================================
33// 2. Service handling tokens
34// ===========================================
35
36@Injectable()
37export class TokenService {
38  constructor(private jwtService: JwtService) {}
39
40  // Creating access token
41  createAccessToken(legionary: { id: string; username: string; rank: string }) {
42    const payload = {
43      sub: legionary.id,
44      username: legionary.username,
45      rank: legionary.rank,
46      type: 'access',
47    };
48    return this.jwtService.sign(payload, { expiresIn: '1h' });
49  }
50
51  // Creating refresh token
52  createRefreshToken(legionary: { id: string }) {
53    const payload = {
54      sub: legionary.id,
55      type: 'refresh',
56    };
57    return this.jwtService.sign(payload, { expiresIn: '7d' });
58  }
59
60  // Token verification
61  verifyToken(token: string) {
62    try {
63      const decoded = this.jwtService.verify(token);
64      console.log('Token decoded:', decoded);
65      return decoded;
66    } catch (error) {
67      console.log('Token invalid or expired!');
68      throw error;
69    }
70  }
71
72  // Decoding without verification (e.g. to read the payload)
73  decodeToken(token: string) {
74    return this.jwtService.decode(token);
75  }
76}
77
78// ===========================================
79// 3. JWT token structure
80// ===========================================
81
82console.log('=== JWT Structure ===');
83console.log('Header:  { alg: "HS256", typ: "JWT" }');
84console.log('Payload: { sub: "1", username: "Caesar", rank: "consul" }');
85console.log('Signature: HMACSHA256(header + payload, secret)');
86console.log('');
87console.log('Token = base64(header).base64(payload).signature');
88console.log('Access Token: short lifetime (1h)');
89console.log('Refresh Token: long lifetime (7d)');
90

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. How many parts does a JWT token consist of?

  2. 2. What does the Payload section of a JWT token contain?

These are 2 of 5 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the JwtModule configuration, setting the secret from ConfigService, expiration time to '24h', and issuer to 'LegionaryFleet'

  • Horizontal ordering

    Arrange the parts of a JWT token from left to right (as they appear in the string)

  • Code editor

    Complete the JwtStrategy class that extends PassportStrategy(Strategy) and implements a validate method checking if the user exists

  • Click in order

    Arrange the elements of the JwtStrategy class declaration in the correct order

  • Vertical ordering

    Order the steps of the login process using JWT

  • Horizontal ordering

    Arrange the syntax for defining the JWT_SECRET variable in a .env file

  • Code editor

    Create a JwtPayload interface in TypeScript containing the fields: sub (number), username (string), email (string), role (string), iat (number), and exp (number)

Useful articles