DEV Community

Cover image for Securing APIs: Techniques and Best Practices for Security
Luca Nicolini for Claranet

Posted on • Originally published at claranet.com

Securing APIs: Techniques and Best Practices for Security

We write and design our API very carefully, but when we perform penetration tests we usually find many problems. In this article I'll tell about a technique to project REST API and structure the resources, to facilitate and make security controls more effective.

The basic principle is that we don't perform security check on a group of resources, but on a one single resource or better on URL paths.

The best practice is to define for each path-HTTPverb couple, a list of user groups that can have access to it. Often this solution is facilitated by the web framework. Below we will see some examples of path protection.

GET  /api/users     > only authenticated users
POST /api/notes     > only admins
GET  /api/products  > open to all users
Enter fullscreen mode Exit fullscreen mode

Let's take the list of users as an example, and let's assume that a user wants to access to read or modify his data: I believe that the user must access to the resource identified by the following path.

/api/users/{id}
Enter fullscreen mode Exit fullscreen mode

The handler of this call should check if the authenticated user is the same of the user identified by id passed as parameter.

Now let's analyse the order list problem of the specific user. The following one is a bad solution.

GET /api/orders
Enter fullscreen mode Exit fullscreen mode

In this case, in order to force the selection of orders by user, we can add a required querystring parameter like this.

GET /api/orders?userId={id}
Enter fullscreen mode Exit fullscreen mode

So, we must write code to filter orders by user identifier, and check if the user identifier is accessible from the authenticated user. In this way I dirty the code with conditional check. Moreover, required querystring parameter is not nice to see.

Another way is filtering orders by user identifier using the identifier inside of the authentication data, for example the JWT token. So, it's not possible to use the same path to get a list of all orders, for example useful for an administration panel.

Now I'll show my best solution to solve this problem to get a list of user orders.

GET /api/users/{id}/orders
Enter fullscreen mode Exit fullscreen mode

So, I recycle the user id validation and expose only the orders connected with the specified user. This is a cleaned solution for paths design and to minimize the code for access checking.

What if I wanted to access to the full order list?

GET /api/orders
Enter fullscreen mode Exit fullscreen mode

In this case I set the check directly on the path from web framework, for example, I can authorise only administrators. So, we have two path for orders, one for administration panel, another one for user orders. The security is guaranteed because the user can read only his orders if he isn't an administrator.

I have just shown you a way to design REST API that allows you to structure resources and sub-resources based on the security rules required by the application domain.

Image description

Now I want to show how it's possible to implement this type of security on REST API. I will take into consideration Fastify, a famous web framework for Node.js which allows to create fast web api with cleaned and quality code. The programming language used for the following examples is TypeScript.

With Fastify it's possible to create plugins to extend the framework. In this case, we'll create a plugin to check the jwt token.

import fp from 'fastify-plugin'
import { jwtDecode } from 'jwt-decode'
import { FastifyAuthFunction } from '@fastify/auth'
import {
    FastifyPluginAsync,
    FastifyReply,
    FastifyRequest
} from 'fastify'

type TUserGroup = 'admin' | 'operator' | 'customer'

declare module 'fastify' {
    interface FastifyInstance {
        jwtAuth: (userGroups: TUserGroup[]) => FastifyAuthFunction
    }

    interface FastifyRequest {
        authUser: {
            userId: string,
            userGroup: TUserGroup
        }
    }
}

const jwtAuthUtility: FastifyPluginAsync = async server => {
    const jwtAuth = (userGroups: TUserGroup[]): FastifyAuthFunction =>
        async (request: FastifyRequest, response: FastifyReply) => {
            const authorization = request.headers.authorization ?? ''

            if (!authorization.startsWith('Bearer ')) {
                response.status(401).send()
            }
            else {
                const token = authorization.replace('Bearer ', '')
                const payload = jwtDecode<{
                    userId: string,
                    userGroup: TUserGroup
                }>(token)

                request.authUser = { ...payload }

                if (!request.authUser.userId ||
                    !request.authUser.userGroup ||
                    !userGroups.includes(request.authUser.userGroup))
                    response.status(401).send()
            }
        }

    server.decorate('jwtAuth', jwtAuth)
}

export default fp(jwtAuthUtility)
Enter fullscreen mode Exit fullscreen mode

The code above represents a fastify-plugin, which retrieves the jwt token from the HTTP header and decodes it to extract the token payload. We assume that, in the payload, there are a user identifier and a user group. The array of groups passed as parameter is the list of enabled groups. In this example we don't validate the token, but normally we must specify the validation rule for expiration and secret key. Token validation is not important in this case. Notice that token data are copied from payload to the request, so, user identifier and user group are available to the request object. Now let's see how to use the plugin.

import { FastifyPluginAsync } from 'fastify'
import { findAllOrders } from './repository'

const routes: FastifyPluginAsync = async server => {
    server.get('/api/orders',
        {
            preValidation: server.jwtAuth(['admin'])
        },
        async () => findAllOrders()
    )
}

export default routes
Enter fullscreen mode Exit fullscreen mode

In this way we've created an endpoint for the path /api/orders that provides all orders. This endpoint is accessible only for admin users, if another user tries to call it he'll receive the error 401 Unauthorized. So we have written a security control, simple, clean, reusable and effective, which will help us to face the penetration test.

Now it's time to create a route that responds to path /api/users/{id} to get the user detail. Notice that this route is opened for admin, operator and customer.

    server.get<{ Params: { id: string } }>('/api/users/:id',
        {
            preValidation: server.jwtAuth([
                'admin',
                'operator',
                'customer'
            ]),
            preHandler: (request, response, next) => {
                checkUser(
                    request.params.id,
                    request.authUser.userId
                ) ? next() :
                    response.status(403).send()
            }
        },
        async (request, response) => {
            const user = findUser(request.params.id)

            if (!user) {
                response.status(404).send()
            }
            else return user
        }
    )
Enter fullscreen mode Exit fullscreen mode

In the first step I check the user with checkUser, one can think that this function contains logic to determine if the request user is accessibile from the current user. If the current user can't access to the request user, then I return 403 Forbidden. In the second step I get the user with findUser, one can think that this function retrieves user from database. If the user is not present on database, then I return 404 Not Found. In case of positive outcome of the two functions seen above, I return the user data with status code 200.

Now I'll show the route that responds with the orders of the specific user.

    server.get<{ Params: { id: string } }>('/api/users/:id/orders',
        {
            preValidation: server.jwtAuth(['customer']),
            preHandler: (request, response, next) => {
                checkUser(
                    request.params.id,
                    request.authUser.userId
                ) ? next() :
                    response.status(403).send()
            }
        },
        async (request, response) => {
            const user = findUser(request.params.id)

            if (!user) {
                response.status(404).send()
            }
            else return findOrdersByUser(request.params.id)
        }
    )
Enter fullscreen mode Exit fullscreen mode

In the code above we've seen that the route is accessible only for customer user, so admin user must use the route /api/orders to get the list of orders. Customer user can access only his orders, and we've chosen that operator user do not access to order list. Notice that we recycled the function checkUser and so we've centralized his logic.

Now it's time to talk about how to test this solution to make sure that the next penetration tests will be fine. But we'll talk about this in the next episode.


Do you find this article helpful? Then you’ll love the webinar based on this article! Claranet Italy is going live on Thursday, February 6th, at 12:00 CET with a Securing APIs: Techniques and Best Practices webinar.
It’s going to be public and in English so that everyone is welcome! Also, feel free to invite friends and colleagues.
The speaker for this first session will be Leonardo Montini, who will demonstrate how little changes in the way we design our APIs can make a significant impact on security.
Register now: https://bit.ly/secure-api-sed-dt
See you there!

Top comments (0)