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,
};
}
}
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,
};
}
}
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;
}
}
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
Top comments (2)
It makes so many problems, I think how token storage on frontend is responsibility of frontend, it's strange to make it on backend part. For example, when client\browser updated and user agent changed that makes JWT token is invalid. I don't think that it's expect behavior
You're absolutely right. That's why, it should be only used as an additional security part on your really crucial endpoint, like in finance systems, where clients logout after a couple of minutes of inactivity, etc.