How to authenticate and protect REST API routes using JWT with refersh token rotation

Implement login,register and protect endpoints of your rest api with jwt

After reading this article, you will able to

  1. Authenticate users with their username/email and password
  2. Understand the uses of accessToken and refreshToken
  3. Protect api endpoints from unauthorized clients by validating accessToken
  4. Allow multi-logins with ability to revoke all session
  5. Have a template with signup,signin and endpoint protection to kickstart your next rest api

The code is available on github

I suggest to read the full article first and then start coding

The source code structure is as follows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.
├── controllers
│   ├── auth
│   │   ├── login.ts
│   │   ├── logout.ts
│   │   ├── refreshAccessToken.ts
│   │   └── register.js
│   └── users.ts
├── db # functions to make calls to the database
│   ├── connect.ts
│   ├── tokens.ts
│   └── users.ts
├── index.ts
├── middlewares
│   ├── validateRegistrationData.ts
│   └── verifyTokens.ts
├── routes
│   ├── auth.ts
│   ├── index.ts
│   └── users.ts
└── utils
    ├── genToken.ts
    ├── hashString.ts
    └── verifyToken.ts
  • The database is using sqlite so you don’t have to setup any database in your system
  • This project uses prisma ORM which will give you typescript support with excellent autocompletion experience

prisma/schema.prisma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
model User {
    id            String         @id @default(uuid())
    email         String         @unique
    username      String         @unique
    password      String
    refreshTokens RefreshToken[]
}

model RefreshToken {
    id          String   @id
    hashedToken String
    user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
    userId      String
    createdAt   DateTime @default(now())
}
  • User and RefreshToken has one to many relationship
  • One user can have multiple refreshToken so that logins from multiple devices can be persisted
  • Run npx prisma generate to generate prisma client code. Then run npx prisma migrate dev to create necessary tables according to the schema
  • Tip : You can use npx prisma studio to interect with the database in a Web GUI

routes/auth.ts:

1
2
3
4
5
6
7
8
9
const router = express.Router()

router.post("/auth/login", login)
router.post("/auth/register", validateRegistrationData, register)
router.get("/auth/refresh", verifyRefreshToken, refreshAccessToken)
router.delete("/auth/logout", logout)
router.delete("/auth/logout_all", logout_all)

export { router as authRouter }

All routes are prefixed with “/api”

sequenceDiagram
    participant c as Client
    participant s as Server
    participant d as Database
    c ->> s: Registration Data
    Note over c,s: POST /auth/register
    s -) s: validate registration data
    alt Invalid Data
        s->> c : error message
    else Valid Data
        s ->> d : Create User
        s->>c: success
    end

Route : router.post("/auth/register", validateRegistrationData, register)

  • The request body should contain username, email and password
  • We will create a middleware to validate the registration data

middlewares/validateRegistrationData.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const validateRegistrationData = async (req, res, next) => {
    const { email, username, password } = req.body

    if (!password)
        return res.status(400).json({ error: "No password provided" })

    if (!username)
        return res.status(400).json({ error: "No username provided" })

    if (!email) return res.status(400).json({ error: "No email provided" })

    let user = await findUserByUsernameOrEmail(username, email)
    if (user) {
        let error = "Email already exits"
        if (user.email !== email) error = "Username already exits"
        return res.json({ error })
    }

    req.user = {
        email,
        username,
        password,
    }

    next()
}
  • If an account with the same username or email already exists then return
  • Else attach the user object to req and go to the next function

controllers/auth/register.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export const register = async (req, res) => {
    const user = await createUser(req.user)
    if (!user) return res.json({ error: "Registration Failed" })

    const data = {
        username: user.username,
        email: user.email,
    }

    res.json({ data })
}
  • Hash the password before storing to the database
1
2
3
4
5
6
7
const createUser = async (user: any) => {
    user.password = await hashString(user.password)

    return db.user.create({
        data: user,
    })
}

Route : router.post("/auth/login", login)

sequenceDiagram
    participant c as Client
    participant s as Server
    participant d as Database
    c ->> s: Login Data
    Note over c,s: POST /auth/login
    s -) d: lookup user
    d -) s: Result
    alt user doesn't exist
        s->> c : User not Found
    else user exists
        alt  credentials mismatch
        s ->> c : Wrong password
        else credentials match
        s -> s  : create refreshToken
        s -) d  : save refreshToken
        s ->> c : { accessToken, refreshToken }
        end
    end
  • The request body should contain { username, password }
  • Here username field can contain email also, providing the option to log in with both username and email
  • Return an error if user doesn’t exist or password is incorrect
  • Create accessToken and refreshToken
  • Send the refreshToken to be saved as a httpOnly cookie with 30 days validity
  • Send the accessToken

controllers/auth/login.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
export const login = async (req, res) => {
    try {
        if (!req.body.username)
            return res.status(400).json({ error: "No Username provided" })
        if (!req.body.password)
            return res.status(400).json({ error: "No Password provided" })

        const { username, password } = req.body

        // User can log in with username or email
        const user = await findUserByUsernameOrEmail(username, username)

        if (!user) return res.status(404).json({ error: "User Not Found" })

        const match = await bcrypt.compare(password, user.password)

        if (!match) return res.status(401).json({ error: "Wrong Password" })

        const accessToken = genAccessToken(user)
        const tokenId = randomUUID()
        const refreshToken = genRefreshToken(user, tokenId)

        res.cookie("refreshToken", refreshToken, {
            httpOnly: true,
            maxAge: 24 * 60 * 60 * 30 * 1000, // 30 days
        })

        // add the token to the database
        addRefreshToken(tokenId, user.id, refreshToken)

        return res.json({ accessToken })
    } catch (error) {
        return res.status(500).json({ error: "Internal Error" })
    }
}
  • refreshToken is sensitive data, hence you shouldn’t store it in plain text
  • You could either hash it or encrypt it
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const addRefreshToken = async (
    id: string,
    userId: number,
    refreshToken: string
) => {
    const hashedToken = await hashString(refreshToken)
    return db.refreshToken.create({
        data: {
            id,
            userId,
            hashedToken,
        },
    })
}

Route : router.get("/auth/refresh", verifyRefreshToken, refreshAccessToken)

sequenceDiagram
    participant c as Client
    participant s as Server
    participant d as Database
    c ->> s: refreshToken
    Note over c,s: POST /auth/refresh
    s -> s : verify refreshToken
    alt expired or invalid
        s -) c : Unauthorized
    else token is valid
        s -) d : lookup token
        d -) s : result
        alt token doesn't exist in db
            s-)c : Unathorized
        else token exists
            s-) d : delete old refrshToken
            s-) s : generate new refreshToken
            s-) d : save new refreshToken
            s-)c : { accessToken, refreshToken }
        end
    end
  • If the token is expired or tampered with then the verification will fail
  • If the verification passes but the token doesn’t exist in the db, then you can suspect that someone is trying to use an old token that might be stolen so you return Unauthorized
  • Otherwise, delete the refreshToken that was in the cookie of the request, create new token, save it to the database and send set it as a httpOnly cookie, this practice is called refresh token rotation
  • Send the accessToken
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export const refreshAccessToken = async (req, res) => {
    const user = req.user
    const newTokenId = randomUUID()
    const newRefreshToken = genRefreshToken(user, newTokenId)

    res.cookie("refreshToken", newRefreshToken, {
        httpOnly: true,
        maxAge: 24 * 60 * 60 * 1000 * 30,
    })

    // refresh token rotation
    deleteRefreshTokenById(user.jwtid)
    addRefreshToken(newTokenId, user.id, newRefreshToken)
    //

    const accessToken = genAccessToken(user)
    return res.json({ accessToken })
}
sequenceDiagram
participant c as Client
participant s as Server
participant d as Database
    c ->> s : { accessToken }
    Note over c,s : GET /protected

    s -> s : verify accessToken

    alt invalid token
        s-)c : Unauthorized
    else valid token
        s -) d : query resource
        d -) s : result
        s -) c : grant access to protected resource
    end

As an example, /users can be used as a protected endpoint

Route : router.use("/users", verifyAccessToken, listUsers)

middlewares/verifyAccessToken.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export const verifyAccessToken = (req, res, next) => {
    const authHeader = req.headers["authorization"]
    const token = authHeader && authHeader.split(" ")[1]
    if (token == null) return res.sendStatus(401)
    const user = tokenVerifier.validateAccessToken(token)
    if (user.tokenError)
        return res.status(401).json({
            error: "Invalid Access token",
            tokenError: user.tokenError,
        })

    req.user = user
    return next()
}
  • The client has to send the token in the authorization header following the format Bearer $token
  • If the token is not valid, the error will be returned (such as TokenExpiredError or JsonWebTokenError if the token is modified)
  • Otherwise the server will query the database and send the list of users to the client

Route : router.delete("/auth/logout", logout)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export const logout = async (req, res) => {
    const refreshToken = req.cookies.refreshToken
    if (!refreshToken)
        return res.status(401).json({ error: "No Refresh Token" })

    // does not check if it exists in the db
    const user = tokenVerifier.verifyRefreshToken(refreshToken)

    if (user.tokenError)
        return res.status(401).json({
            error: "Invalid Refresh token",
            tokenError: user.tokenError,
        })

    res.clearCookie("refreshToken")
    deleteRefreshTokenById(user.jwtid)
    return res.sendStatus(200)
}

Route : router.delete("/auth/logout", logout_all)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
export const logout_all = async (req, res) => {
    const refreshToken = req.cookies.refreshToken
    if (!refreshToken)
        return res.status(401).json({ error: "No Refresh Token" })

    res.clearCookie("refreshToken")

    // does not check if it exists in the db
    const user = tokenVerifier.verifyRefreshToken(refreshToken)

    if (user.tokenError)
        return res.status(401).json({
            error: "Invalid Refresh token",
            tokenError: user.tokenError,
        })

    // delete all tokens associated with this user
    deleteAllRefreshTokens(user.id)
    return res.sendStatus(200)
}

Notice 2 things,

  1. There’s no check to see if the token exists in db
  2. Access token verification is skipped

Lets consider a scenario where the client’s cookie is hijacked, so attacker has the refreshToken

  • Now he is going to use that refreshToken to get new accessToken
  • Which will invalidate the refreshToken of the client from which it was hijacked
  • That client may be the legitimate user
  • And now that client can’t get any new accessToken
  • So if accesToken verification or the check to see if token exists was in db was present then that client wouldn’t be able to logout

You can use curl to perform all the requests All the commands listed below is written on tests/api-test-curl.sh

If you use Insomnia, which is an awesome open source api testing tool, you can import all the requests from tests/api-test-insomnia.json

1
2
3
4
5
6
7
8
curl --request POST \
  --url http://localhost:5000/api/auth/register \
  --header 'Content-Type: application/json' \
  --data '{
	"username" : "gr523",
	"email" : "[email protected]",
	"password" : "Pass82G9"
}'
1
2
3
4
5
6
7
8
curl --request POST \
  --url http://localhost:5000/api/auth/login \
  --header 'Content-Type: application/json' \
  --cookie-jar "cookie.txt" \
  --data '{
	"username" : "gr523",
	"password" : "Pass82G9"
}'

Output: {"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo"}

  • The cookie will be saved in cookie.txt
  • Copy the value of accessToken to your clipboard
  • Paste the accessToken after Bearer
1
2
3
curl --request GET \
  --url http://localhost:5000/api/api/users \
  --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo'

Output: {"users":[{"id":"3ccd6f3a-d132-443f-95c4-4f02cbe7d4e1","username":"gr523","email":"[email protected]"}]}

  • Modify the value of the accessToken
1
2
3
curl --request GET \
  --url http://localhost:5000/api/api/users \
  --header 'Authorization: Bearer xxxxxxxxxxxxxxxxNiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo'

Output {"error":"Invalid Access token","tokenError":"JsonWebTokenError"}

The validity duration of the accessToken is set to 5 minutes, after that you can’t use that token to access protected resources

The response will be {"error":"Invalid Access token","tokenError":"TokenExpiredError"}

You can change the validity duration in utils/genToken.ts

  • Use the value of refreshToken from cookie.txt
1
2
3
curl --request GET \
  --url http://localhost:5000/api/auth/refresh \
  --cookie refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqd3RpZCI6IjQwY2VkYmRjLWM2NmQtNGJlYy1hNjc4LTg0MWJkZDhlMTBkMyIsImlkIjoiM2NjZDZmM2EtZDEzMi00NDNmLTk1YzQtNGYwMmNiZTdkNGUxIiwidXNlcm5hbWUiOiJncjUyMyIsImVtYWlsIjoiZ3I1MjNAZ21haWwuY29tIiwiaWF0IjoxNjgxOTI4MzgwLCJleHAiOjE2ODQ1MjAzODB9.WDk-YbqxX7_yCr8ATbDxbCV-W6EUNzxZPchPaHnuZAI

Or in linux you can use sed,

1
2
3
curl --request GET \
  --url http://localhost:5000/api/auth/refresh \
  --cookie refreshToken="$(sed -En '/refreshToken/s/.*refreshToken\s*(.*)/\1/p' cookie.txt)"

Log in again and request refresh, the respone will be {"error":"Invalid Refresh Token","tokenError":"OldToken"}

You have read up to this point, you are ready to take kickstart your next rest-api project. Would appreciate any feedback. Tell me, if you like this style of tutorial or what could be changed to make it better

Related Content