Maintaining STATE with JWT (Get full controll)
Maintaining STATE with JWT
When I start building REST API, I wondered how JWT works. It felt like something magical, that we can verify authorized users without using the session ID. It saves a lot of storage at the server-side and we don't have to query the database for each and every request (to check whether the session ID exists and whom it belongs to). I thought that sessions will die soon and every application will implement JWT. But it wasn't right. Sessions are still better than JWT in many cases.
Let's see a few scenarios where JWT really sucks.
- The token is still valid after the password reset.
- The token is not invalidated even if the account deleted.
- The server cannot invalidate the token after logout. (technically there is no real logout, the token is valid until it expires).
- You cannot control how many devices can be logged in at a time.
The above-mentioned issues can only be solved by tracking the state of the application. So that we don't have to switch back to sessions. we can solve these problems with few hacks. Let's begin.
Our first goal is to invalidate the token if the user resets the password or the account has been deleted.
From the above image, you can see that we are signing the JWT with our secret key, which is usual. But, we are going to use different secret for each user to achieve our first goal.
signing the token:
Note:
I assume that you are familiar with JWT and how it works
Here I'm not going to focus on code rather than I'll be focusing on logic to solve the problem.
I assume that you are familiar with JWT and how it works
Here I'm not going to focus on code rather than I'll be focusing on logic to solve the problem.
Invalidate token on password reset and account deletion:
Our first goal is to invalidate the token if the user resets the password or the account has been deleted.
From the above image, you can see that we are signing the JWT with our secret key, which is usual. But, we are going to use different secret for each user to achieve our first goal.
signing the token:
const TEMP_KEY = SECRET_KEY + User.password
const TOKEN = await JWT.sign(payload, TEMP_KEY)
You can see here that I've used the user's password(it will be hash, mostly bcrypt) concatenated with our secret key to sign the token.
Note: Here you have to make an initial database query before verifying the user before each request. This way we can also invalidate token if the account has been deleted. This might not look cool, But we don't have a way to invalidate the token on password reset without using sessions. We have to sacrifice some speed to make our application secure.
verifying the token:
const TOKEN = String(req.headers.authorization).split(" ")[1]
const { id } = await JWT.decode(token)
let User = findById(id)
const TEMP_KEY = SECRET_KEY + User.password
User = JWT.verify(token, TEMP_KEY)
req.user = User
In the above code, we are extracting user Id from token before verifying (decoding since it's just Base64). And then we are querying the database to find that user. If the user account has been deleted, then the DB would return null, we can catch this error and act according to that. If the user exists then we can generate our secret (TEMP_KEY) that we used to sign the token to verify it.
This way if a user changes their password, then the secret would change, thus their token will be invalidated. Yeah, we did this without using sessions. Let's move on to the next scenario.
Logout Functionality and Manage LogedIn devices:
Now let's focus on how can we manage state with stateless JWT.To achieve this, we have to manage the state on server side. We just need one extra column in our DB (state).
Allow only one device:
We can allow only one device to log in at a time. This is simple, we just need to store some value (state) which will be unique for every login session(we use timestamp here).In the above image, you can see that we have a key called "state" in our JWT payload. This is just a timestamp when the user logs in.
const state = new Date().valueOf()
await findByIdAndUpdate(
User.id,
{
"state":state
}
)
const payload = {
"id": User.id,
"state": state,
"iat": 1516239022,
"exp": 1516339022
}
const TEMP_KEY = SECRET_KEY + User.password
const TOKEN = await JWT.sign(payload, TEMP_KEY)
The above code saves timestamp as a state on Login. This should be verified at middleware before each request.
const TOKEN = String(req.headers.authorization).split(" ")[1]
const { id, state } = await JWT.decode(token)
let User = findById(id)
if (User.state !== state) throw Something
const TEMP_KEY = SECRET_KEY + User.password
User = JWT.verify(token, TEMP_KEY)
req.user = User
In the above code, we are verifying the state in every request. If the state doesn't match, we can throw an error.
You have to set this again when the user hits the logout endpoint. So that the timestamp will not match in old tokens. When the user signs in from a new device, they will be lost access from older devices.
Allow from Multiple Devices:
Some times we want to allow users from multiple devices. We can do this simply by using JSON as a status.
const state = `d_${new Date().valueOf()}`
const curState = JSON.parse(User.state)
const newState = curState[state] = true
await findByIdAndUpdate(
User.id,
{
state: JSON.stringify(newState)
}
)
const payload = {
"id": User.id,
"state": state,
"iat": 1516239022,
"exp": 1516339022
}
const TEMP_KEY = SECRET_KEY + User.password
const TOKEN = await JWT.sign(payload, TEMP_KEY)
In the above code, we are creating a unique id that starts with "d_" (just shorthand of a device) concatenated with a timestamp as key for JSON. this way we can add multiple login sessions as JSON. We can verify this in middleware easily.
const TOKEN = String(req.headers.authorization).split(" ")[1]
let { id, state } = await JWT.decode(token)
state = JSON.parse(state)
let User = findById(id)
if (!User.state[state]) throw Something
const TEMP_KEY = SECRET_KEY + User.password
User = JWT.verify(token, TEMP_KEY)
req.user = User
Here we are verifying if the state exists in our DB state. if not we are throwing the error. Thus we are able to handle multiple sessions with JWT.
Here we can also limit the number of allowed logins per account, by simply checking how many numbers of keys in the state. We can also logout from all devices or from specific devices easily. (Just replace the boolean value for state value with the user-agent header to track which device was logged in )
const state = `d_${new Date().valueOf()}`
const curState = JSON.parse(User.state)
const newState = curState[state] = req.headers['user-agent']
You can cut the user-agent string according to your needs(It would be larger).
I hope I've answered the issues that I've mentioned above. If you have different approaches to solve these problems, feel free to comment below. If you have any doubts please ask in the comments section. If you like my post follow me on twitter.
Thanks for reading my article. Please leave your opinion as a comment below.
follow me on twitter @CyberSrikanth
Nice dood
ReplyDeleteGreat!. Querying the database on every request in the only pain point.
ReplyDeleteyeah, but still you are doing only when reset password or account deletion which may not occur most of the time in return you're getting strong authentication.
DeleteYeah, but I think It's worth if we see this in security perspective.
Delete🤗👌
ReplyDelete