DEV Community

Cover image for The Ultimate Guide to JWT server-side auth (with refresh tokens)
Kati Frantz
Kati Frantz

Posted on • Originally published at katifrantz.com

The Ultimate Guide to JWT server-side auth (with refresh tokens)

Hello, my name is Kati Frantz, and thank you so much for checking out this tutorial. I want to talk about how to handle JWTs effectively and securely on the server-side.

Most tutorials only cover one part, issuing the access token, but this is only part of the solution and can be very insecure in a production application. Let's start by understanding the authentication strategy and learn how best to implement it.

Understanding access tokens and refresh tokens

Let's take an example application, a social networking mobile app. We have two goals here:

  1. Maintain the user's login status for as long as possible, with no interruptions and a great user experience. For example, I've been logged in to Instagram for about two years now.
  2. We should make sure providing a great user experience to the user does not compromise on security.

Let's start with the first goal, a forever login. When the user downloads our application, they register a new account or log in to an existing account. The API for our mobile app returns an access token, which could be a JWT. Since we want the user to be logged in forever, we set the token expiry to 10 years. When the user wants to fetch their feed, search users, or perform any authenticated requests to the API, the mobile app sends along with this access token.

Great, this solves the first goal. Now let's talk about the second goal. Security. If an attacker takes possession of the access token (and trust me, they can), we have a huge security problem because they have access to the user's account for the next 10 years.

Refresh tokens

We can improve our application security to make it very tough for an attacker to hijack the access token, but we can never be completely secure. The best way to protect the user now is to make sure the access token is as short as possible. 10 minutes is ideal. Depending on how safe and secure your mobile app or browser client is, you can increase this time.

Now we have a short-lived access token, it's only valid for 10 minutes, which means if an attacker takes possession of the token, their access expires in 10 minutes or less. But again, this breaks our first condition. If the user's access expires every 10 minutes and they have to login again, the user experience is very poor.

This is where refresh tokens come in. When the user logs in, our API returns two tokens, an access token, and a refresh token. The access token expires in 10 minutes, and the refresh token expires in 5 years.

This refresh token does not grant access to the API but can be used to request a new access token. After 10 minutes of usage, a few seconds before the user's session expires, we make an API call in the background to the API, sending the refresh token.

The API identifies and authenticates this refresh token, and returns a new access token to the mobile app which expires in 10 minutes.

Great. We've solved the first goal, and the user experience is back. Security is partially solved. Since we send the refresh token over the network, it becomes a little more difficult for a hijacker to get a hold of the refresh token.

We are not completely safe yet. If they ever hijacked the refresh token, we're back to the same problem, because the attacker can now generate new access tokens. If your application has very good security to store the refresh token with a very low possibility of being compromised, then there's no need to fear. If not, such as in-browser environments, we need another way to secure refresh tokens.

Refresh token rotation

The Internet Engineering Task Force suggests using a technique called refresh token rotation to secure refresh tokens. You can view the details of the draft here.

First, every time the user authenticates, we generate new access and refresh tokens and return to the mobile app. We also persist the new refresh token to the database.

Whenever the mobile app requests our backend with the refresh token to get a new access token, we'll generate a new refresh token and save it to a database. Next, we'll invalidate the refresh token that was just used.

This means the mobile app can only use a refresh token once. In the event where an attacker gains access to the refresh token and attempt to use it, the backend automatically detects this, notices the token has already been used and immediately blocks the user's account.

Now if the attacker uses the refresh token before the mobile app does, in less than ten minutes after hijacking the refresh token, the mobile app attempts a refresh, and this also results in the user's account being blocked, so we've protected both ways.

At this point, the API notifies support that a user's credentials have been compromised, and once we figure out and patch any security issues, we can unblock the user's account and ask them to re-authenticate.

diagram showing refresh token rotation flow

Creating a secure server-side JWT authentication with refresh tokens

If you want this functionality out of the box with absolutely no effort, you can run yarn create tensei-app my-app and get a fresh new project. The project has less than 18 lines of code and implements this backend architecture for you. Let's look at some code snippets from the tensei codebase to see how this is done.

We need two database tables: users and tokens. The users table has the standard fields we need for authentication such as email and password. The tokens table has the token, expires_at, last_used_at and user_id fields. The last_used_at field would help us know if a token has already been used once to acquire an access token before.

First, a user attemps to login. This is how the login controller looks:

    private login = async (ctx) => {
        const { db, body } = ctx
        const { email, password, token } = await this.validate(
            body.object ? body.object : body
        )

        const user = await db.findOne('User', {
            email
        })

        if (!user) {
            throw ctx.authenticationError('Invalid credentials.')
        }

        // Check if the user's account has been blocked. The user can be automatically blocked 
        // if data compromise is detected.
        if (user.blocked_at) {
            throw ctx.forbiddenError('Your account is temporarily disabled.')
        }

        if (!Bcrypt.compareSync(password, user.password)) {
            throw ctx.authenticationError('Invalid credentials.')
        }

        ctx.user = user

        return this.getUserPayload(ctx)
    }

    private async getUserPayload(ctx) {
        return {
            access_token: this.generateJwt({
                id: ctx.user.id
            }),
            refresh_token: await this.generateRefreshToken(ctx),
            expires_in: this.config.tokensConfig.accessTokenExpiresIn,
            user: ctx.user
        }
    }

    private async generateRefreshToken(
        ctx
    ) {
        const plainTextToken = this.generateRandomToken(48)

        // Expire all existing refresh tokens for this customer.
        await ctx.db.nativeUpdate('Token', {
            user: ctx.user.id
        },
        {
           expires_at: Dayjs().subtract(1, 'second').format(),
           // Also mark unused refresh token as used, in case the user logged in twice and got more than one
           // refresh token at a time
           last_used_at: Dayjs().subtract(1, 'second').format()
        }
        )

        const entity = ctx.db.create('Token', {
            token: plainTextToken,
            user: ctx.user.id,
            type: TokenTypes.REFRESH,
            expires_at: Dayjs().add(
                this.config.tokensConfig.refreshTokenExpiresIn,
                'second'
            )
        })

        await ctx.db.persistAndFlush(entity)

        return plainTextToken
    }

    public generateJwt(payload) {
        return Jwt.sign(payload, this.config.tokensConfig.secretKey, {
            expiresIn: this.config.tokensConfig.accessTokenExpiresIn
        })
    }
Enter fullscreen mode Exit fullscreen mode



A few moments after we send the access and refresh tokens to the user, the mobile application attempts to use the refresh token to get a new access token:

    private async handleRefreshTokens(ctx) {
        const { body } = ctx

        const refreshToken = body.refresh_token

        if (!refreshToken) {
            throw ctx.authenticationError('Invalid refresh token.')
        }

        const token = await ctx.db.findOne('Token', {
            token: refreshToken,
            type: TokenTypes.REFRESH
        })

        if (!token) {
            throw ctx.authenticationError('Invalid refresh token.')
        }

        if (token.last_used_at) {
            // This token has been used before.
            // We'll block the user's access to the API by marking this refresh token as compromised.
            // Human interaction is required to lift this limit, something like deleting the compromised tokens.

            ctx.db.assign(token, {
                compromised_at: Dayjs().format()
            })

            ctx.db.assign(token.user, {
                blocked_at: Dayjs().format()
            })

            ctx.db.persist(token)
            ctx.db.persist(token.user)

            await ctx.db.flush()

            throw ctx.authenticationError('Invalid refresh token.')
        }

        if (!token.user || Dayjs(token.expires_on).isBefore(Dayjs())) {
            token && (await ctx.db.removeAndFlush(token))

            throw ctx.authenticationError('Invalid refresh token.')
        }

        ctx.db.assign(token, {
            last_used_at: Dayjs().format(),
            expires_at: Dayjs().subtract(1, 'second').format()
        })

        await ctx.db.persistAndFlush(token)

        ctx.user = token.user

        return this.getUserPayload(ctx)
    }
Enter fullscreen mode Exit fullscreen mode

Conclusion

So there you have it, how to implement refresh tokens and refresh token rotation in your application to ensure maximum security. A good thing to do is make sure your application's needs fit the security measures you are taking.

Thank you very much for reading this far 🎉.

If you've found this useful, please follow me on Twitter, and subscribe to my newsletter to be instantly notified when I share a new post.

Top comments (0)