How to make JWT more secure in NestJS

axotion

Kamil Fronczak

Posted on October 21, 2023

How to make JWT more secure in NestJS

Recently, I've been thinking about how to make some of my crucial endpoints more secure when using locally stored JWT.

It may not be the best practice for security (because of the possibility of XSS attacks), but this was a requirement not set by me. I had to adapt.

So in order to make this more secure, I've found a solution, that, hopefully, will help you as well.

The Problem

I assume that we all know what JWT is - a token issued by the backend service, which cannot be modified by the frontend, because that would change the signature and automatically invalidate the token.

It sounds great, till we meet the situation where the token may be stolen - like in my case with XSS attack. Many websites store tokens in local storage instead of HTTP-only cookie, so they are vulnerable as well.

The Solution

So, when it's not possible to change the way frontend stores that token, we have to change the way we issue and validate the token, and this is the moment when I want to introduce you to a simple solution - JWT with hashed fingerprint, but let's see the code

Let's assume, that we have an endpoint to issue tokens, let's call it sign-in endpoint


@Controller('v1/sign-in')
export class SignInAction {
  constructor(private jwtService: JwtService) {}

  @Post()
  async handle(
    @Req() request: Request,
    @Body() body: SignInHttpRequest,
  ): Promise<SignInHttpResponse> {
    const ip =
      request.headers['x-forwarded-for'] || request.socket.remoteAddress;

    const userAgent = request.headers['user-agent'];

    const token = this.jwtService.sign({
      email: body.email,
      fingerprint: getSHA512Hash(`${ip}${userAgent}`), // It's worth to mention, that to make it even more secure, you should add salt
    });

    return {
      token,
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

So what we are doing here, is basically collecting user agent and remote IP from user that is connecting, hashing it, and then putting it into JWT token, so even if user or attacker would like to see what's inside JWT token, they will only see some random hashed value

But how it's gonna boost my security you ask? Let's take a look at our second endpoint, show the fingerprint

@Controller('v1/fingerprint')
@UseGuards(AuthGuard(JWT_STRATEGY))
export class ShowFingerPrintAction {
  @Get()
  async handle(@Req() request) {
    return {
      fingerprint: request.user.fingerprint,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

At first, it's nothing special. Endpoint that returns fingerprint from request, so what makes it more secure? The JWT_STRATEGY!

export const JWT_STRATEGY = 'JWT';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'hard!to-guess_secret',
      passReqToCallback: true,
    });
  }

  async validate(request: Request, payload: any): Promise<any> {
    const fingerprint = payload.fingerprint;

    const ip =
      request.headers['x-forwarded-for'] || request.socket.remoteAddress;

    const userAgent = request.headers['user-agent'];
    const calculatedFingerprint = getSHA512Hash(`${ip}${userAgent}`); // It's worth to mention, that to make it even more secure, you should add salt

    if (fingerprint !== calculatedFingerprint) {
      throw new BadRequestException('Invalid fingerprint');
    }

    return payload;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, in strategy, we are extracting the same values as when we extracted them from the sign-in endpoint and comparing them - if the IP or user agent has changed, then we will deny access to our fingerprint endpoint.

This is the case where even if the attacker stole our token, he can't do anything with it, because

  • He doesn't know what the hash inside the fingerprint is
  • We won't allow him to connect to our endpoints, because of different IP and user agent (and maybe more factors)

That's how we can protect our crucial endpoint in a very simple way, but of course, there are some tradeoffs

  • For every call, we have to calculate the sha512 hash
  • If the user has a dynamic IP he will have to log in frequently

For the first problem, you could of course check only for IP and don't even hash it when issuing a token, but then the potential attacker knows the vector of the attack (IP spoofing or other methods), so it's safer to sacrifice some performance for security.

I hope that this method will help you to introduce an additional layer of security to your crucial endpoints.

Link to working source code: Source Code

UPDATE:

  • I've added comments about additional salt for sha512 calculations, it makes it less reversible
  • X-FORWARDER-FOR header can be easily spoofed unless you use services like Cloudflare, keep that in mind
💖 💪 🙅 🚩
axotion
Kamil Fronczak

Posted on October 21, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related