Hey there! I am Alexander Brichak, Golang developer at NIX.
Using commonly accepted solutions and technologies, developers rarely think about the risks of a particular solution if used incorrectly and whether it is suitable for the tasks to which they are trying to apply. This fully applies to such a popular technology as JWT.
In this article, I want to discuss the problems that arise when using JWT tokens in client applications, and also consider some interesting solutions for a backend server implemented in Golang.
Why Golang? The high performance of this language makes it easier to work with high-load software and microservice architecture. Its scope is wide, and the syntax is easy to learn. The Golang community is growing all over the world. Therefore, NIX has developed a free learning platform for beginners.
For those who already deal with Go, the article will be useful when creating Web applications in Golang, and for those who are looking for ready-made solutions for implementing such non-standard JWT functions as logging and automatic logging of users.
How to make sure that the data received by the web application server (backend or API) was actually sent by this or that user? This helps identify the JSON Web Token technology. When using web tokens to access APIs for client applications, always remember that the token can fall into the hands of attackers. Therefore, usually after authentication, the user receives not just one token, but two:
short lived access-token. It can be reused to get resources from the server. The life cycle of a token is displayed in the payload part and is often limited to hours, or even minutes, depending on the application. The standard JWT libraries, when validating a token, by default check to see if it has expired. The attacker who received the access token will have a very limited time to act on behalf of the user.
refresh-token with a long term of use. It allows you to renew a pair of tokens after the access token expires.
A similar mechanism is adopted, in particular, in the OAuth 2.0 protocol.
In frontend applications, when using JWT, the scheme of work will be:
- as soon as the server returned access and refresh tokens in response to the username and password, the system remembers this pair of tokens
- on each call to the API, the frontend application adds a header with an access token to the HTTP request. If the token is not expired, the server returns a response
- if the access token is expired, the server responds with an HTTP 401 Unauthorized error status. To get a new pair of tokens, the application first needs to access a special API endpoint on the server and pass a refresh token. Then repeat the HTTP request to get the data with the already generated access token.
In JavaScript, for example, it is convenient to implement such a mechanic in the axios library using interceptors.
How to make a token invalid and why you need it
JSON Web token was originally created as a stateless mechanism for authorization, in order not to store information on the server. The validity period of the token is recorded automatically. After the expiration of time, it simply becomes invalid and is not accepted by the server. This scheme is excellent because it does not require additional server resources to remember the state.
Let's imagine that we need to implement a logout - the user exits the application. On the frontend, this is easily accomplished by "forgetting" a pair of tokens. To continue working with the application, the user must again enter his username and password and receive a new set of tokens. But what if the token fell into the hands of an attacker? In the event of theft, if the hacker got the refresh token, he will have enough time to do something on behalf of the user. While a real user has no way to revoke tokens and stop the attacker. The only thing that will save you is blocking the user on the server or replacing the secret string with which tokens are signed. After this operation, all issued tokens will become invalid.
Therefore, RFC6749, which describes the OAuth 2.0 protocol, requires additional measures to identify the illegal use of the refresh token. Here, you can use the authentication of the user who sent this token. Another way is to rotate the refresh token, after which it is invalidated and stored on the server. If in the future someone tries to use it, it will signal a possible hack.
All these considerations, most of the time, lead to the need to transform stateless tokens into stateful, i.e. storing some information on the server that allows you to declare the tokens of a certain user invalid. Then, with each user request, the server first checks the validity of the token based on the information in the token itself (in particular, the expiration date), and then based on the information on the server.
There are many ways to organize this process, for example:
- store blacklist tokens on the server. The list is formed after logging out or updating a pair of tokens. When accessing the server with a token from the blacklist, the user will receive an authorization error;
- store the blacklist of users on the server. It can contain user ID and logout time. Any tokens issued to the user earlier than the moment of logout will be invalid;
- store information on the issued tokens on the server, linked to the user ID. The token passed by the user application in the request to the server will be valid if its information matches the data about the token issued for this user;
Exotic methods:
- create secret lines for signing tokens for each user. This will allow you to change the line to invalidate the tokens of a specific user;
- change user ID if his tokens are compromised. After that, the old tokens will not match any user.
To validate the token, many of these methods require an additional query to the database each time the user accesses the server. To reduce the load on the database and speed up the processing of the request, other options for storing information about tokens are used. For example, in-memory database.
A lot of other ideas you can find here and here.
Automatic log out and JWT
In many user applications, it is required to implement automatic logout - disconnecting the user in case of inactivity for some time. The function especially concerns applications that provide access to personal data and other "sensitive" information (bank accounts or records in the medical history). In particular, the American HIPAA standards apply such a requirement to applications that provide access to users' secure electronic health information (ePHI).
Of course, it is important that the user frontend application somehow tracks the user's inactivity period and makes a request to logout when the inactivity period is exceeded. But given the notion that the backend server should not rely on the validation routines of the frontend application, it becomes clear that the backend needs its own way of detecting user inactivity.
The main flow of interaction between the frontend application and the outside world occurs through the API on the backend server. Therefore, the user's activity on his part can be considered the execution of requests to the API, and inactivity - the period between two requests of the same user. The backend server's job is to track this time interval between requests and force logout if the maximum inactivity period is exceeded.
NIX team's solution using stateful tokens
Our approach goes beyond stateless tokens and involves storing information about issued tokens on the server - in Redis. In addition to the user ID, we add another ID to the tokens to match the token with the information recorded on the server. This article describes in detail such a scheme for working with tokens.
The main benefit of a Redis database is its automatic logout. Thanks to the mechanism of automatic expiration (expiration) of data in the Redis database, it was possible to establish such a method of storing and updating information about issued tokens, in which, after the expiration of the maximum allowed period between user requests, information about his tokens is automatically deleted from the Redis database. Tokens become invalid.
For example, take a boilerplate application written in the Golang Echo web framework. It has already implemented registration and user login, updating a pair of tokens using a refresh token, and there is a set of tests. Next, we will consistently change it to get the desired result. There is also Swagger documentation here, which is handy to use to test our changes. Updates made to the boilerplate application code are available in the repository under the "feature/JWT-logout" branch.
Improving the template application
The boilerplate application uses the dgrijalva / jwt-go library to work with JWTs. Besides the standard set of claims fields, this library allows you to describe additional fields. In the application, this makes it possible to write to the token the ID of the user to whom it was issued. The library supports the NewWithClaims () and Parse () functions used in the AuthHandler application to create and validate tokens. Also, the Echo framework has a JWT middleware that uses the specified library to validate tokens. This middleware is hooked up in the ConfigureRoutes () function of the template application that declares the routing.
The current implementation of the boilerplate application uses exclusively stateless tokens. In this case, there is no way to declare the tokens invalid before their expiration date. In addition to the impossibility of a full-fledged logout, this leads to the following scenario: with one refresh token, you can contact the API endpoint / refresh several times. Our further changes should solve this problem as well.
Let's move on to the implementation of our ideas. In the Redis database, we will store certain information about the issued tokens for each user.
We need to add the following components to the application code:
- connecting to the Redis database
- recording information about issued tokens in Redis when generating a pair of tokens
- checking the existence of a token in Redis for routes protected by authorization
- deleting records from Redis when the user accesses API endpoint/logout.
Redis connection
Since our templated application uses docker-compose, we can easily add a container with a Redis database by declaring it in docker-compose.yml:
echo_redis:
image: redis
container_name: ${REDIS_HOST}
restart: unless-stopped
ports:
- ${REDIS_EXPOSE_PORT}:${REDIS_PORT}
networks:
- echo-demo-stack
To create a container, we need to enter the values REDIS_HOST, REDIS_PORT, REDIS_EXPOSE_PORT into the .env file. To connect to the Redis server, you need to add the RedisConfig structure to the config package:
package config
import "os"
type RedisConfig struct {
Host string
Port string
}
func LoadRedisConfig() RedisConfig {
return RedisConfig{
Host: os.Getenv("REDIS_HOST"),
Port: os.Getenv("REDIS_PORT"),
}
}
Then - the InitRedis () function into the db package. To connect it uses the library [github.com/go-redis/redis](github.com/go-redis/redis)
func InitRedis(cfg *config.Config) *redis.Client {
addr := fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port)
return redis.NewClient(&redis.Options{
Addr: addr,
})
}
We call the InitRedis () function in the NewServer () method of the server package when starting the application:
func NewServer(cfg *config.Config) *Server {
return &Server{
Echo: echo.New(),
DB: db.Init(cfg),
Redis: db.InitRedis(cfg),
Config: cfg,
}
}
Storing information about tokens
Now that we have a connection to Redis, we can start saving information about issued tokens. To do this, we only need to change the service code in the token package. We will save not the token itself, but some unique UID. This identifier will also appear in the claims of the corresponding token. After parsing the token that came in the user's request and checking the UID with what is stored on the server, we will always know if this token is active.
Add the UID field to JwtCustomClaims and to the createToken () method:
type JwtCustomClaims struct {
ID uint `json:"id"`
UID string `json:"uid"`
jwtGo.StandardClaims
}
We will create the UID using the github.com/google/uuid library. Let's also add the generated UID to the list of output parameters of the createToken () method:
func (tokenService *Service) createToken(userID uint, expireMinutes int, secret string) (
token string,
uid string,
exp int64,
err error,
) {
exp = time.Now().Add(time.Minute * time.Duration(expireMinutes)).Unix()
uid = uuid.New().String()
claims := &JwtCustomClaims{
ID: userID,
UID: uid,
StandardClaims: jwtGo.StandardClaims{
ExpiresAt: exp,
},
}
Now let's declare a structure that will be saved on the server every time a pair of tokens is generated:
type CachedTokens struct {
AccessUID string `json:"access"`
RefreshUID string `json:"refresh"`
}
Since our service in the token package will need a connection to Redis, let's change the service declaration, and the NewTokenService () method as follows:
type Service struct {
server *s.Server
}
func NewTokenService(server *s.Server) *Service {
return &Service{
server: server,
}
}
The last change concerns the GenerateTokenPair () method. After receiving the UID of each created token and writing these UIDs into the CachedTokens structure, save the JSON of this structure in Redis with the key "token- {ID}", where the ID of the user who logged in will be substituted for the ID:
func (tokenService *Service) GenerateTokenPair(user *models.User) (
accessToken string,
refreshToken string,
exp int64,
err error,
) {
var accessUID, refreshUID string
if accessToken, accessUID, exp, err = tokenService.createToken(user.ID, ExpireAccessMinutes,
tokenService.server.Config.Auth.AccessSecret); err != nil {
return
}
if refreshToken, refreshUID, _, err = tokenService.createToken(user.ID, ExpireRefreshMinutes,
tokenService.server.Config.Auth.RefreshSecret); err != nil {
return
}
cacheJSON, err := json.Marshal(CachedTokens{
AccessUID: accessUID,
RefreshUID: refreshUID,
})
tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON), 0)
return
}
Now we are truly protected from an attacker. If someone steals our tokens, each time a user logs into the system with a username and password, new tokens will erase information about old tokens, making them invalid. Note that in this implementation, the user will be able to simultaneously use the system on only one device. When logging in from another device, the tokens issued for the first one will become invalid.
The task remains to add the code to check the existence of the token sent by the user.
Checking for the existence of tokens in Redis
Add the ValidateToken () method to the service in the token package. This method retrieves the token data from Redis, which is stored with the key "token- {ID}". The ID will be replaced by the user ID from the claims token sent in the request. Next, the UID of the token from the request is compared with the UID from Redis. If they match, then the user has sent a valid token.
func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) error {
cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
cachedTokens := new(CachedTokens)
err := json.Unmarshal([]byte(cacheJSON), cachedTokens)
var tokenUID string
if isRefresh {
tokenUID = cachedTokens.RefreshUID
} else {
tokenUID = cachedTokens.AccessUID
}
if err != nil || tokenUID != claims.UID {
return errors.New("token not found")
}
return nil
}
We will call it in the RefreshToken () method in AuthHandler:
func (authHandler *AuthHandler) RefreshToken(c echo.Context) error {
refreshRequest := new(requests.RefreshRequest)
if err := c.Bind(refreshRequest); err != nil {
return err
}
claims, err := authHandler.tokenService.ParseToken(refreshRequest.Token,
authHandler.server.Config.Auth.RefreshSecret)
if err != nil {
return responses.ErrorResponse(c, http.StatusUnauthorized, "Not authorized")
}
if authHandler.tokenService.ValidateToken(claims, true) != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
user := new(models.User)
To do this, the ParseToken () method will need to be slightly redone so that it does not return the standard set of JWT claims, but a link to JwtCustomClaims, from which we can extract the token identifier:
func (tokenService *Service) ParseToken(tokenString, secret string) (
claims *JwtCustomClaims,
err error,
) {
token, err := jwtGo.ParseWithClaims(tokenString, &JwtCustomClaims{},
func(token *jwtGo.Token) (interface{}, error) {
if _, ok := token.Method.(*jwtGo.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return
}
if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid {
return claims, nil
And, of course, the ValidateToken () method must be called for validation on all token-protected routes. To do this, we'll add one more middleware in the auth.go file:
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Get("user").(*jwtGo.Token)
claims := token.Claims.(*tokenService.JwtCustomClaims)
if tokenService.NewTokenService(server).ValidateToken(claims, false) != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
return next(c)
}
}
}
Then we use it after the embedded JWT middleware when declaring routes in the ConfigureRoutes () function:
authMW := middleware.JWT(server.Config.Auth.AccessSecret)
validateTokenMW := middleware.ValidateJWT(server)
apiProtected := server.Echo.Group("")
apiProtected.Use(authMW, validateTokenMW)
Since the built-in JWT-middleware, after validating the token, adds it to the request context with the key "user", our additional middleware for token validation can extract the token from the context and work with it - run the ValidateToken () method of the service in the token package to validate its data in Redis.
Removing information about tokens when logging out
To implement the logout, it remains to add the code to remove the user token entry from Redis. Let's add the Logout () method to AuthHandler:
func (authHandler *AuthHandler) Logout(c echo.Context) error {
user := c.Get("user").(*jwtGo.Token)
claims := user.Claims.(*tokenservice.JwtCustomClaims)
authHandler.server.Redis.Del(fmt.Sprintf("token-%d", claims.ID))
return responses.MessageResponse(c, http.StatusOK, "User logged out")
}
We use simplified token validation (no additional validation in Redis). Let's add the “/ logout” route to the *ConfigureRoutes ()* function:
authMW := middleware.JWT(server.Config.Auth.AccessSecret)
server.Echo.POST("/logout", authHandler.Logout, authMW)
validateTokenMW := middleware.ValidateJWT(server)
Automatic logout
Suppose we are faced with automatically logging out a user in case of 10 minutes of inactivity. Setting the validity period of the access token does not solve the problem. If the user received a couple of tokens and next time accessed the API after 11 minutes, we will return the 401 Unauthorized status. However, the user can then apply to the endpoint/refresh and, thanks to the longer validity period of the refresh token, he will receive a new pair of tokens. We cannot allow this to happen.
On the other hand, setting a period of 10 minutes for a refresh token is also not an option. When the user contacts the API 9 minutes after receiving a pair of tokens, from this moment we must start a new countdown for automatic logout and allow the user to access the API (with an access token or with a refresh token for /refresh) no later than 19 minutes after receiving the first pair of tokens.
As I noted earlier, Redis's TTL mechanism is very handy for solving this problem.
Let me remind you that when in the GenerateTokenPair () method we write data to Redis after creating tokens, the third parameter in the Redis.Set () method specifies the record expiration date. When this time expires, Redis automatically deletes the entry. If we pass 0 as this parameter, then the record will have an unlimited TTL:
tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON), 0)
By controlling the TTL of the record in Redis, we will achieve automatic invalidation of tokens after a specified time. In this case, the period of automatic logout can be set to any, regardless of the validity period of the tokens.
What should be done:
- set TTL to write to Redis in GenerateTokenPair () method at 10 minutes. This step will work on the initial user login and on the subsequent refresh of the pair of tokens by /refresh.
- extend the TTL of this entry for another 10 minutes each time the user makes a successful API request.
Let's create a constant const AutoLogoffMinutes = 10 and change the “expiration” parameter in GenerateTokenPair ():
tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON),
time.Minute*AutoLogoffMinutes)
Using the Redis Expire command, add the TTL extension of the record with tokens after successfully checking its existence in the ValidateJWT middleware in the auth.go file:
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Get("user").(*jwtGo.Token)
claims := token.Claims.(*tokenService.JwtCustomClaims)
if tokenService.NewTokenService(server).ValidateToken(claims, false) != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
time.Minute*tokenService.AutoLogoffMinutes)
return next(c)
}
}
}
Let's say we set the automatic logout period when the user is inactive for 10 minutes. The access token is valid for 20 minutes, the refresh token is valid for 60 minutes. The automatic logout mechanism can be perfectly understood from the diagram:
At the first stage, the frontend application sends the username and password and receives a response from the API with access and refresh tokens. The token UID entry is placed in Redis with a TTL of 10 minutes.
In the second and third stages, the application sends various API requests. Each of them lags behind the previous one by no more than 10 minutes. Each time the TTL of a record in Redis with token UIDs is moved by 10 minutes. At the same time, the token validity period itself remains unchanged.
At the fourth stage, the frontend application sends a request to the API after 20 minutes have passed since the generation of tokens and receives a 401 Not Authorized response since The access token has expired. By contacting the endpoint/refresh with a refresh token, the frontend receives a new set of tokens. Redis writes information about new tokens with a fresh TTL of 10 minutes. Old tokens are no longer valid.
At the fifth stage, the application sends a request to the API 12 minutes after the previous stage. Even though the tokens did not expire, the Redis entry was deleted after a TTL of 10 minutes. The frontend will not be able to receive new tokens until the user logs in again. Thus, the automatic logout is completed.
User information
There is one problem with our token validation code. Suppose a user is logged in and their token information is stored in Redis. Immediately after that it was inactivated (for example, the system administrator deleted a user record from the database or assigned it the “inactive” status). We need to make sure that the user's application can no longer work with the API using the issued set of tokens. At the moment when the administrator inactivates a user, information about that user's tokens should be automatically removed from Redis. But what if you forgot to do it?
To avoid such problems, when validating a token, we can check not only the existence of an entry in Redis, but also the presence/activity of a user entry in the database. This requires an additional query to the database.
On the other hand, in the process of processing a request, it is often the case that the user record is searched in the database:
the server needs information about the current user. It will help determine the rights to perform certain actions;
when making queries that change data in the database, the backend application code must check that the user record exists in the database and the user is not inactivated.
To implement this idea, add code to the ValidateToken () method of the token service to find a user record in the database. We will also add the found user record to the list of returned parameters of the specified method:
func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) (
user *models.User,
err error,
) {
cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
cachedTokens := new(CachedTokens)
err = json.Unmarshal([]byte(cacheJSON), cachedTokens)
var tokenUID string
if isRefresh {
tokenUID = cachedTokens.RefreshUID
} else {
tokenUID = cachedTokens.AccessUID
}
if err != nil || tokenUID != claims.UID {
return nil, errors.New("token not found")
}
user = new(models.User)
userRepository := repositories.NewUserRepository(tokenService.server.DB)
userRepository.GetUser(user, int(claims.ID))
if user.ID == 0 {
return nil, errors.New("user not found")
}
return user, nil
}
The GetUser () method of the repository can retrieve not only a user record from the users table, but also in one JOIN request get personal data and user roles from the user_details, user_roles and others tables (if such tables are in the database and this information is useful for processing the request). These changes will allow us to remove the code for checking the user's record from the RefreshToken () method:
func (authHandler *AuthHandler) RefreshToken(c echo.Context) error {
refreshRequest := new(requests.RefreshRequest)
if err := c.Bind(refreshRequest); err != nil {
return err
}
claims, err := authHandler.tokenService.ParseToken(refreshRequest.Token,
authHandler.server.Config.Auth.RefreshSecret)
if err != nil {
return responses.ErrorResponse(c, http.StatusUnauthorized, "Not authorized")
}
user, err := authHandler.tokenService.ValidateToken(claims, true)
if err != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
accessToken, refreshToken, exp, err := authHandler.tokenService.GenerateTokenPair(user)
if err != nil {
return err
}
res := responses.NewLoginResponse(accessToken, refreshToken, exp)
return responses.Response(c, http.StatusOK, res)
}
There will be a more significant change in the middleware ValidateJWT code. Let's add the found user record to the request context with the "currentUser" key, making it possible to access this information at all subsequent stages of request processing:
// Middleware for additional steps:
// 1. Check the user exists in DB
// 2. Check the token info exists in Redis
// 3. Add the user DB data to Context
// 4. Prolong the Redis TTL of the current token pair
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Get("user").(*jwtGo.Token)
claims := token.Claims.(*tokenService.JwtCustomClaims)
user, err := tokenService.NewTokenService(server).ValidateToken(claims, false)
if err != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
c.Set("currentUser", user)
server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
time.Minute*tokenService.AutoLogoffMinutes)
return next(c)
}
}
}
Optimizing ValidateToken () Code
Note that two sequential actions take place in the ValidateToken () method of the token package:
- retrieving a record with information about tokens from Redis;
- retrieving information about the user from the database.
Golang allows us to execute these requests in parallel. We will save a little processing time for the request (in fact, only the time required to retrieve and parse the Redis record into the Golang structure). But when you can optimize your code, why not?
We use the golang.org/x/sync/errgroup package. It will allow you to run multiple goroutines and wait for them to complete successfully. However, in case of an error in at least one of them, the execution of the entire group will be canceled. The ValidateToken () method code will look like this:
func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) (
user *models.User,
err error,
) {
var g errgroup.Group
g.Go(func() error {
cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
cachedTokens := new(CachedTokens)
err = json.Unmarshal([]byte(cacheJSON), cachedTokens)
var tokenUID string
if isRefresh {
tokenUID = cachedTokens.RefreshUID
} else {
tokenUID = cachedTokens.AccessUID
}
if err != nil || tokenUID != claims.UID {
return errors.New("token not found")
}
return nil
})
g.Go(func() error {
user = new(models.User)
userRepository := repositories.NewUserRepository(tokenService.server.DB)
userRepository.GetUser(user, int(claims.ID))
if user.ID == 0 {
return errors.New("user not found")
}
return nil
})
err = g.Wait()
return user, err
}
Another small optimization awaits in the middleware ValidateJWT. Extending TTL records with information from tokens in Redis can also be done in a goroutine. So further processing of the request will not be blocked while we are waiting for the end of this operation:
c.Set("currentUser", user)
go func() {
server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
time.Minute*tokenService.AutoLogoffMinutes)
}()
return next(c)
Have we done everything correctly?
If you look at the resulting code, you can see that we still make a query to the main database when checking the existence of a user. This means that we could store information about the user's tokens and the date of their last use in this database, and also implement logout and automatic logout without using the Redis database. Why exactly Redis?
this allows you to unload the main database from storing unusual data and unnecessary requests (information about tokens and the moment the user last accessed the API is rather short-term);
a mechanism for automatic deletion of records with expired TTL allows you to more elegantly implement automatic logout and not take up space on the database server for storing expired information;
other data from the database can be stored in Redis. For example, information about user roles and permissions.
Where to store information about issued tokens on the server should be decided based on the specifics of each specific application.
How to store tokens on the client
The inclusion of tokens in the response body during the login procedure often leads to the fact that front-end developers decide to store the received tokens in the local storage of the browser. This avoids the need to re-login when a user forces a page to refresh or opens a new tab. The solution is very vulnerable to XSS attacks, during which the attacker's code can gain access to the local storage.
An alternative option is often used, in which the access token is passed in the response body and stored further in the memory of the frontend application, and the refresh token is placed in the HttpOnly cookie. This approach helps to better defend against XSS attacks, but at the same time is vulnerable to CSRF attacks.
The approach of placing a refresh token in a cookie ideally also implies a change in the architecture of the backend application, in which the authorization service is in a separate domain. Thus, cookies with a refresh token will be transmitted only when interacting with the authorization service.
But what about the session?
It is believed that the use of tokens in any other way, except for confirming the identity of the user, is no longer included in the JWT functions and should be implemented differently.
The session mechanism is one of the approaches to solving the problem of logout and automatic logout. The simplest way to do this is to add a cookie with a specific string to the server's response. The string can be the server response time, signed with a secret key. The next time the frontend application makes a request, the server will compare the time of the previous request contained in the cookie with the current time. If more than the specified number of minutes have passed since the previous request, the user will receive an HTTP 401 Unauthorized error status. Thus, the access token will only be valid when paired with a cookie, which contains information about the user's session.
But this does not remove the security issue in the event of attacks. Therefore, to improve the session mechanism, you should use other methods of storing session information (in the main database, in an additional in-memory database, in the server file system, etc.).
Our approach to user authentication using JWT, while not without its drawbacks, actually works. The approach of using sessions to store refresh tokens or other information about user status is also promising.
Maintaining application security is always a complex process that requires complex solutions. There is no ideal option, because each specific application dictates its own needs.
Top comments (1)
what a great post!