We use cookies to enhance your experience on the site
CodeWorlds

JWT Tokens - Roman passes

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.

Go to CodeWorlds