JWT Authentication in NestJS
Implementing Access & Refresh Tokens the Right Way

Authentication is one of those areas where it's easy to get things wrong. In this post, I'll show you how I implemented JWT-based authentication with access and refresh tokens in my NestJS backend – including the pitfalls I encountered along the way.
Why Two Tokens in the First Place?
Before diving into the code: why not just use a single token with a long lifespan? The answer lies in the security-UX tradeoff.
An access token is sent with every API request. If it gets intercepted, an attacker gains access to your system. That's why it should be short-lived – in my case, 15 minutes. The problem: nobody wants to log in every 15 minutes.
This is where the refresh token comes in. It's long-lived (7 days) but is only used to obtain new access tokens. It never leaves the client's secure storage except for this single purpose. If the access token gets compromised, the damage is time-limited. If the refresh token gets compromised, it can be invalidated without forcing the user to change their password.
Architecture Overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────▶│ Controller │────▶│ Service │
│ (Frontend) │◀────│ + Guards │◀────│ + JWT │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Strategy │ │ Users │
│ (Passport) │ │ Service │
└─────────────┘ └─────────────┘NestJS makes this relatively straightforward with the Passport module. The core components are the AuthService for token logic, the JwtStrategy for validation, and Guards for route protection.
Setup
First, the required packages:
bash
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcryptAnd two environment variables for the secrets – it's crucial that access and refresh tokens use different secrets:
env
# Generate with: openssl rand -base64 32
JWT_ACCESS_SECRET=your-access-secret-here
JWT_REFRESH_SECRET=your-refresh-secret-hereUser Entity: Storing Passwords Correctly
First rule: never store passwords in plain text. We hash with bcrypt:
typescript
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index({ unique: true })
@Column({ length: 120 })
email!: string;
@Column({ length: 200 })
passwordHash!: string;
@Column({ default: false })
isAdmin!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}Users Service: Hashing and Verification
The UsersService handles hashing during registration and verification during login:
typescript
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User) private repo: Repository<User>
) {}
async create(data: { email: string; password: string; isAdmin?: boolean }) {
// Cost factor 12: good balance between security and performance
const passwordHash = await bcrypt.hash(data.password, 12);
const user = this.repo.create({
email: data.email,
passwordHash,
isAdmin: data.isAdmin || false,
});
return this.repo.save(user);
}
async verify(email: string, password: string) {
const user = await this.findByEmail(email);
if (!user) return null;
// bcrypt.compare is timing-safe against timing attacks
const valid = await bcrypt.compare(password, user.passwordHash);
return valid ? user : null;
}
findByEmail(email: string) {
return this.repo.findOne({ where: { email } });
}
findById(id: string) {
return this.repo.findOne({ where: { id } });
}
}The cost factor of 12 for bcrypt is a solid middle ground. Higher means more security but also longer wait times during login. With 12, the hash time is around 250ms – noticeable enough to slow down brute-force attacks, but not so long that users get impatient.
Auth Service: The Heart of It All
This is where the actual token magic happens:
typescript
@Injectable()
export class AuthService {
constructor(
private users: UsersService,
private jwt: JwtService,
private cfg: ConfigService,
) {}
async login(email: string, password: string) {
const user = await this.users.verify(email, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Access token: contains user info, short-lived
const accessPayload = {
sub: user.id,
email: user.email,
isAdmin: user.isAdmin
};
const accessToken = await this.jwt.signAsync(accessPayload, {
secret: this.cfg.get<string>('JWT_ACCESS_SECRET'),
expiresIn: '15m',
});
// Refresh token: minimal payload, long-lived
const refreshToken = await this.jwt.signAsync(
{ sub: user.id },
{
secret: this.cfg.get<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d',
},
);
return {
accessToken,
refreshToken,
user: { id: user.id, email: user.email, isAdmin: user.isAdmin },
};
}
async refresh(refreshToken: string) {
// Validate with the OTHER secret!
const payload = await this.jwt.verifyAsync<{ sub: string }>(
refreshToken,
{ secret: this.cfg.getOrThrow<string>('JWT_REFRESH_SECRET') },
);
// User still exists? Could have been deleted
const user = await this.users.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
// Token rotation: completely new token pair
const newAccessToken = await this.jwt.signAsync(
{ sub: user.id, email: user.email, isAdmin: user.isAdmin },
{
secret: this.cfg.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: '15m',
},
);
const newRefreshToken = await this.jwt.signAsync(
{ sub: user.id },
{
secret: this.cfg.get<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d',
},
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
}An important detail: in refresh(), we reload the user from the database. This ensures the new access token contains current data – for example, if their admin status has changed.
JWT Strategy: Token Validation with Passport
The strategy is automatically invoked when a guard protects a route:
typescript
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(cfg: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: cfg.getOrThrow<string>('JWT_ACCESS_SECRET'),
ignoreExpiration: false,
});
}
validate(payload: { sub: string; email: string; isAdmin: boolean }) {
// Whatever is returned here ends up in req.user
return {
id: payload.sub,
email: payload.email,
isAdmin: payload.isAdmin
};
}
}Auth Controller
The controller exposes the endpoints:
typescript
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private auth: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
@UseGuards(ThrottlerGuard) // Rate limiting against brute-force
@UsePipes(new ValidationPipe({ whitelist: true }))
async login(@Body() dto: LoginDto) {
return this.auth.login(dto.email, dto.password);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
refresh(@Body() body: { refreshToken: string }) {
return this.auth.refresh(body.refreshToken);
}
@Get('me')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth('JWT-auth')
getMe(@Request() req: Request & { user: { id: string; email: string } }) {
return req.user;
}
}The @HttpCode(HttpStatus.OK) on POST requests is important – by default, NestJS returns 201 for POST, but with login/refresh we're not actually creating anything new.
Admin Guard: Role-Based Access
For admin-only routes, we need a custom guard:
typescript
@Injectable()
export class AdminGuard extends AuthGuard('jwt') {
async canActivate(context: ExecutionContext): Promise<boolean> {
// First check JWT
const isAuthenticated = await super.canActivate(context);
if (!isAuthenticated) {
throw new UnauthorizedException('Authentication required');
}
// Then check admin status
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user?.isAdmin) {
throw new ForbiddenException('Admin access required');
}
return true;
}
}The distinction between 401 (Unauthorized) and 403 (Forbidden) matters: 401 means "you're not logged in", 403 means "you're logged in but still not allowed to do this".
typescript
// Usage
@Delete(':id')
@UseGuards(AdminGuard)
deleteProject(@Param('id') id: string) {
// Only admins reach this point
}The Typical Auth Flow
Here's what the complete flow looks like in practice:
1. Login
POST /auth/login
Body: { "email": "user@example.com", "password": "..." }
Response: {
"accessToken": "eyJhbG...",
"refreshToken": "eyJhbG...",
"user": { "id": "...", "email": "...", "isAdmin": false }
}2. API Requests
GET /projects
Header: Authorization: Bearer eyJhbG...3. Token Refresh (when access token expires)
POST /auth/refresh
Body: { "refreshToken": "eyJhbG..." }
Response: {
"accessToken": "eyJhbG...", // New!
"refreshToken": "eyJhbG..." // Also new (token rotation)
}4. Re-Login (when refresh token expires)
After 7 days, the user needs to log in again.
Common Pitfalls
"Token expired" immediately after login
For me, this was caused by server time being off. JWTs are time-based – if your server's clock is wrong, tokens are immediately expired. Solution: enable NTP.
Secrets in code
A classic mistake. Never do this:
typescript
// ❌
secret: 'my-secret-key'
// ✅
secret: this.cfg.getOrThrow<string>('JWT_ACCESS_SECRET')Stale user data in token
That's why we reload the user from the database in refresh(). This way, changes to the user profile are reflected in the token within 15 minutes at most.
Security Checklist
AspectImplementationPassword storagebcrypt, cost factor 12Token lifespanAccess: 15min, Refresh: 7dSeparate secretsDifferent keys for both token typesToken rotationNew refresh token on every refreshRate limitingThrottlerGuard on loginMinimal payloadRefresh token only contains subHTTP status codesCorrectly distinguish 401 vs 403
Conclusion
JWT authentication isn't rocket science, but the details make the difference between "works somehow" and "actually secure". The combination of short-lived access tokens and long-lived refresh tokens with token rotation offers a solid compromise between security and user experience.
The complete code is part of my portfolio backend – if you have questions or something's unclear, feel free to reach out.