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.
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.
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.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.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.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.The legionary carries a pass and does not give the password at every gate:
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,sub (user id), iat (issued at), exp (expires),jwtService.sign(payload) issues the pass, and the server remembers nothing,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.