Skip to content
NestJS ns auth 4 min read

JWT Authentication

JSON Web Tokens (JWTs) are the most common way to authenticate stateless HTTP APIs in NestJS. After a user proves their identity once, the server hands back a signed token that the client returns on every subsequent request. Because the signature proves authenticity, the server never has to store session state — it just verifies the token. The official @nestjs/jwt package wraps the battle-tested jsonwebtoken library and exposes it as a fully injectable JwtService.

Installing the package

You only need the JWT helper package itself. If you plan to pull the secret from configuration (recommended), add @nestjs/config too.

npm install @nestjs/jwt @nestjs/config

Registering JwtModule

JwtModule.register() configures a signing secret and default sign options. Once registered, the module exports a JwtService that you can inject anywhere within that module’s scope.

// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [
    JwtModule.register({
      secret: 'do-not-hardcode-this-in-production',
      signOptions: { expiresIn: '15m' },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

The expiresIn value accepts a number of seconds or a string understood by the ms library, such as "15m", "7d", or "60s".

Warning: Never commit a real secret to source control. The hardcoded value above is illustrative only — load it from the environment using async configuration (shown below).

Async configuration with ConfigService

Hardcoding secrets is unsafe. Use JwtModule.registerAsync() to resolve the secret and expiry at runtime from ConfigService, which reads from your .env file or process environment.

// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [
    ConfigModule, // or rely on a global ConfigModule.forRoot()
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.getOrThrow<string>('JWT_SECRET'),
        signOptions: {
          expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m'),
        },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}
# .env
JWT_SECRET=8f2c4b1e9a7d6c3f0e5b8a1d4c7f0e3b
JWT_EXPIRES_IN=15m

getOrThrow fails fast at boot if the secret is missing, which is far safer than discovering a missing secret when the first login request arrives.

Signing a token on login

Inject JwtService into your service and call signAsync() with a payload. The payload typically carries a stable subject (sub) plus a few non-sensitive claims. Never put passwords or secrets in the payload — JWTs are signed, not encrypted, so anyone can decode them.

// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private readonly users: UsersService,
    private readonly jwt: JwtService,
  ) {}

  async login(email: string, password: string) {
    const user = await this.users.findByEmail(email);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const payload = { sub: user.id, email: user.email, role: user.role };
    return {
      accessToken: await this.jwt.signAsync(payload),
    };
  }
}
// auth/auth.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';

class LoginDto {
  email: string;
  password: string;
}

@Controller('auth')
export class AuthController {
  constructor(private readonly auth: AuthService) {}

  @Post('login')
  login(@Body() dto: LoginDto) {
    return this.auth.login(dto.email, dto.password);
  }
}

Output:

$ curl -X POST localhost:3000/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"hunter2"}'

{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsIjoiYWRhQGV4YW1wbGUuY29tIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MTgzMjAwMDAsImV4cCI6MTcxODMyMDkwMH0.k3yq3..."}

Verifying a token

To verify an incoming token, call verifyAsync(). It checks the signature and expiry, throwing if either fails. The returned object is the decoded payload.

// auth/auth.service.ts (continued)
async verify(token: string) {
  try {
    return await this.jwt.verifyAsync(token);
  } catch {
    throw new UnauthorizedException('Token is invalid or expired');
  }
}

If you only need to read the payload without checking the signature — for example, to inspect an expired token — use this.jwt.decode(token) instead. Decoding does not validate anything, so never trust its output for authorization decisions.

JwtService methods at a glance

MethodPurposeValidates signature?
signAsync(payload, opts?)Create a signed token (Promise)n/a
sign(payload, opts?)Create a signed token synchronouslyn/a
verifyAsync(token, opts?)Verify and decode, throws on failureYes
verify(token, opts?)Synchronous verify and decodeYes
decode(token, opts?)Decode without verifyingNo

Per-call overrides

The options passed to register() are defaults. You can override them on individual calls — useful for issuing a longer-lived refresh token with a different secret from the same JwtService.

const refreshToken = await this.jwt.signAsync(
  { sub: user.id },
  {
    secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
    expiresIn: '7d',
  },
);

Best practices

  • Always load the secret from configuration with getOrThrow so the app refuses to start without it.
  • Keep access tokens short-lived (5-15 minutes) and pair them with refresh tokens for longer sessions.
  • Put only non-sensitive claims in the payload — JWTs are signed and readable, not encrypted.
  • Prefer the async signAsync/verifyAsync methods so you never block the event loop.
  • Use separate secrets for access and refresh tokens to limit the blast radius if one leaks.
  • Validate and parse incoming payloads with a typed interface rather than trusting any.
Last updated June 14, 2026
Was this helpful?