Let's go through this step by step:
Step 1.) User Registration
This step seems perfectly fine. There are guidelines out there on what to keep in mind during user registration, but since that's not the focus of the question, let's just say you're doing fine.
Step 2.) Login
You say you use the PHP function password_hash()
during login, but it should actually be password_verify()
.
password_hash($password, [$algo])
should be called during registration, to create a hash with a random salt, and then store the result in a database. Surprisingly, PHP actually picked sane defaults for this function. PASSWORD_DEFAULT
uses bcrypt by default, which isn't the best algorithm, but certainly not objectionable. You can also use PASSWORD_ARGON2ID
, which is considered state-of-the-art. Just be careful to pick good workload parameters!
password_verify($password, $hash)
should be called with the password provided during login. PHP will grab the correct algorithm, parameters and salt from the provided hash, so you don't even need to know how the hash was originally generated to verify if the password matches.
Step 3.) Token Generation
You generate a token with RS256, which is an asymmetric algorithm. Essentially, an RSA private key is used to sign the JWT payload, and the signature is then validated using the public key.
The advantage here is that the JWT provider does not have to be the same entity as the JWT validator. If this makes sense in your architecture, an asymmetric algorithm is preferable.
An alternative is HS256, which is a symmetric algorithm. The key to generate a valid JWT and to verify if a JWT is valid is identical. If the JWT provider is also the JWT validator, then you can safely use HS256.
Regarding vulnerabilities, there is a vulnerability in some libraries with RS256, where a token can be signed as HS256 using the public key for RS256. The library will attempt to use the RSA public key as HMAC secret-key and the signature will be valid. Not a huge issue these days, but something to keep in mind.
Furthermore, a validity of 2 minutes is extremely short. Keep in mind that from the moment a user logs in, they have a grand total of 2 minutes to spend on your application before they get kicked out. As a comparison: Most banking websites use a timeout of 15 minutes. So unless you have a really good reason to give your users only 2 minutes on your application before they need to re-authenticate, you should give them more time.
Step 4.) Sending the JWT to the Frontend
As long as this is done via TLS it should be fine.
Step 5.) Token Storage
JWT Tokens should not be stored as JS variable, nor in the local storage, nor in the session storage. The reason for this is that these places are very accessible for attackers, who may leverage XSS attacks.
A better place would be a cookie with the HTTPOnly flag set. This disallows (malicious) JavaScript from accessing the token, but you can still use the token for XHR requests. So your legitimate logic should not be affected by it.
Step 6.) Token Verification and Regeneration
There are some problems with your logic here. First of all, tokens are not decrypted, because they were never encrypted in the first place. Tokens are validated. This may sound nitpicky, but when it comes to security, correct terminology is important.
A check for expiration time is perfectly reasonable, and a following redirect to re-authenticate is as well.
The issue is with token regeneration: This is a bad idea. A very bad idea.
In practice, it seems reasonable enough: Use a fast expiration time and regenerate as needed, then the token will become invalid as soon as the user is done using the app, right?
The issue arises from when an attacker steals the token. Once stolen, an attacker can essentially keep the token indefinitely, or rather, indefinitely exchange one valid token for another. Even invalidating one token will do very little, because the token can be exchanged for many other tokens as well.
In fact, if the token is regenerated every time you do something in the app, then you can use one token to generate infinitely many new tokens, and then use those tokens to generate even more. One an attacker has gained access to one token, they can never be stopped again.
This is obviously a bad thing, and can be prevented easily. Give your users two tokens: A refresh token and an access token. The access token should have a small, but still usable expiration window (between 15 and 120 minutes). The refresh token should have a longer validity. If you expect the user to remain authenticated, as is usual with most web applications (Stack Exchange, Twitter, etc...), then the refresh token may last several months to a year.
Essentially, the user would attempt to access a resource with the access token (if it is still valid). If the access token has expired, the long-lived refresh token can be used to get a new short-lived access token.
Why is this advantageous? First of all, access is still somewhat short-lived, so an attacker stealing an access token may only have a limited amount of time in the application. Secondly, a stolen access token cannot be used to persist access. Finally, a stolen refresh token can be invalidated by a user, thus limiting the damage an attacker can do. This is a good article regarding JWT blacklisting.