How to protect your FastAPI routes with Kinde Authentication

By Vlad Ionash — Published

FastAPI is defined by its ease of use and speed, rivaling the likes of API implementations that are written in GO and NodeJS. Most of this performance increase is the result of Starlette and Pydantic, SGI (Asynchronous Server Gateway Interface) versus the WSGI (Web Server Gateway Interface) that Flask and Django are built on. Maybe a more straightforward comparison would be towards Django and Flask. Here’s a table with test results from calling a basic “Hello World” application in two cases:

  1. Average time for the first call after server start up
  2. Average time for consecutive calls after the first call
Martin, A. (n.d.). _Why FastAPI? - featurepreneur - Medium_. Medium. Retrieved February 15, 2024, from https://medium.com/featurepreneur/why-fastapi-69fb172756b7
Martin, A. (n.d.). Why FastAPI? - featurepreneur - Medium. Medium. Retrieved February 15, 2024, from https://medium.com/featurepreneur/why-fastapi-69fb172756b7

The results speak for themselves. Plus, FastAPI support asynchronous code out of the box which could lead to further improvements of test cases that are not simple calls. The concurrent execution of the get_reddit_top coroutines has substantially decreased the API’s response time compared to a serial execution approach.

Let’s add some authentication and secure some routes!

Protecting Methods and Routes Overview

Link to this section

Protecting routes and methods is simple using Kinde. The method is equivalent to setting up the Kinde Flask example but leveraging different libraries that work with async routing that FastAPI employs.

We need to define the criteria of what it means to be authenticated and track user sessions.

This can be accomplished by setting up a simple dictionary to store user_clients , then defining the authenticated status:

# User clients dictionary to store Kinde clients for each user
user_clients = {}

# Dependency to get the current user's KindeApiClient instance
def get_kinde_client(request: Request) -> KindeApiClient:
    user_id = request.session.get("user_id")
    if user_id is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    if user_id not in user_clients:
        # If the client does not exist, create a new instance with parameters
        user_clients[user_id] = KindeApiClient(**kinde_api_client_params)
    kinde_client = user_clients[user_id]
    # Ensure the client is authenticated
    if not kinde_client.is_authenticated():
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    return kinde_client

All you need to do is pass the get_kinde_client into your method’s argument and the authentication process is done for you.

For example, we have here a basic “Hello World” application:

@app.get("/")
def read_root():
    return {"Hello": "World"}

and this is how you would authenticate it with Kinde:

@app.get("/")
def read_root(kinde_client: KindeApiClient = Depends(get_kinde_client)):
    # Now this route requires authentication
    return {"Hello": "World"}

So let’s build an API from scratch and get into the details.

Set up in Kinde

Link to this section
  1. Sign in to your Kinde account.

  2. Go to Settings > Applications.

  3. If you haven’t added your application to Kinde yet:

    1. Select Add application. A dialog window opens.
    2. Enter a name for the app.
    3. Select the Back-end web or Machine-to-machine (M2M) application type based on your needs. This example will focus on the Back-end web.
    4. Under Language/framework, select Python.
    5. Add http://127.0.0.1:8000 to both the Allowed callback URLs and Allowed logout redirect URLs.
    6. Select Save.
  4. If you have already created your app, select View details on the relevant application.

  5. Select Quick start.

  6. Copy the relevant values and keys that will be utilized later in this tutorial.

  7. Select Save.

Using Kinde authentication to gate a newly created FastAPI method

Link to this section

Let’s create a new FastAPI endpoint and gate it with Kinde to show the simplicity of the process.

  1. Open up a new project in your favorite IDE (ie. VSCode, PyCharm, etc).

  2. Install the required dependencies.

    pip install fastapi python-dotenv kinde_python_sdk starlette "uvicorn[standard]"
    
  3. Create a file called app.py with some basic routes:

    from typing import Union
    
    from fastapi import FastAPI
    
    app = FastAPI()
    
    @app.get("/")
    def read_root():
        return {"Hello": "World"}
    
    @app.get("/items/{item_id}")
    def read_item(item_id: int, q: Union[str, None] = None):
        return {"item_id": item_id, "q": q}
    
  4. Test the routes by running the server with:

    uvicorn app:app --reload
    

    This will start the local uvicorn server on http://127.0.0.1:8000.

    Navigating to the base URL will show{"Hello":"World"}.

    Heading over to http://127.0.0.1:8000/items/420 will alternatively show something like{"item_id":420,"q":null}. This confirms that these endpoints are working and are currently not gated with an authentication layer.

    You just created an API that:

    • Receives HTTP requests in the paths / and /items/{item_id}.
    • Both paths take GET operations (also known as HTTP methods).
    • The path /items/{item_id} has a path parameter item_id that should be an int.
    • The path /items/{item_id} has an optional str query parameter q.

    You can also now navigate to http://127.0.0.1:8000/docsto have prebuilt, interactive API documentation ready to go for your API. Neat.

  5. To add Kinde authentication to them, set up a config.py file with the following base template information:

    from kinde_sdk.kinde_api_client import GrantType
    
    SITE_HOST = "localhost"
    SITE_PORT = "5000"
    
    # Quickstart copy/paste overwrite section
    SITE_URL = f"http://{SITE_HOST}:{SITE_PORT}"
    LOGOUT_REDIRECT_URL = f"http://{SITE_HOST}:{SITE_PORT}/api/auth/logout"
    KINDE_CALLBACK_URL = f"http://{SITE_HOST}:{SITE_PORT}/api/auth/kinde_callback"
    CLIENT_ID = "CLIENT_ID"
    CLIENT_SECRET = "SOME_SECRET"
    # Quickstart copy/paste overwrite section
    
    KINDE_ISSUER_URL = "https://[your_subdomain].kinde.com"
    GRANT_TYPE = GrantType.AUTHORIZATION_CODE_WITH_PKCE
    CODE_VERIFIER = "joasd923nsad09823noaguesr9u3qtewrnaio90eutgersgdsfg" # A suitably long string > 43 chars
    TEMPLATES_AUTO_RELOAD = True
    SESSION_TYPE = "filesystem"
    SESSION_PERMANENT = False
    SECRET_KEY = "joasd923nsad09823noaguesr9u3qtewrnaio90eutgersgdsfgs" # Secret used for session management
    

    Make sure to overwrite the section above with the quick-start details you saved from the previous step.

  6. Update the code in app.py with the following:

    from typing import Union
    from fastapi import FastAPI, Depends, HTTPException, status, Request
    from starlette.middleware.sessions import SessionMiddleware
    from kinde_sdk.kinde_api_client import KindeApiClient
    
    import config  # Your configuration file with necessary settings
    
    app = FastAPI()
    app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY)
    
    # User clients dictionary to store Kinde clients for each user
    user_clients = {}
    
    # Dependency to get the current user's KindeApiClient instance
    def get_kinde_client(request: Request) -> KindeApiClient:
        user_id = request.session.get("user_id")
        if user_id is None:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
        if user_id not in user_clients:
            # If the client does not exist, create a new instance
            user_clients[user_id] = KindeApiClient()
    
        kinde_client = user_clients[user_id]
        # Ensure the client is authenticated
        if not kinde_client.is_authenticated():
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
        return kinde_client
    
    @app.get("/")
    def read_root(kinde_client: KindeApiClient = Depends(get_kinde_client)):
        # Now this route requires authentication
        return {"Hello": "World"}
    
    @app.get("/items/{item_id}")
    def read_item(item_id: int, q: Union[str, None] = None, kinde_client: KindeApiClient = Depends(get_kinde_client)):
        # This route also requires authentication
        return {"item_id": item_id, "q": q}
    
    # ... (other routes and callback, login, logout endpoints)
    

    Let’s go over the code to see what is happening here with the additions.

    from typing import Union
    from fastapi import FastAPI, Depends, HTTPException, status, Request
    from starlette.middleware.sessions import SessionMiddleware
    from kinde_sdk.kinde_api_client import KindeApiClient
    
    import config  # Your configuration file with necessary settings
    
    app = FastAPI()
    app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY)
    

    We are importing some libraries, mostly related to Kinde authentication and the session management middleware. Kinde currently does not feature a way to manage sessions by itself, so we will need to do this ourselves. As such, we are going to be utilizing starlette as the middleware for this.

    # User clients dictionary to store Kinde clients for each user
    user_clients = {}
    
    # Dependency to get the current user's KindeApiClient instance
    def get_kinde_client(request: Request) -> KindeApiClient:
        user_id = request.session.get("user_id")
        if user_id is None:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
        if user_id not in user_clients:
            # If the client does not exist, create a new instance
            user_clients[user_id] = KindeApiClient()
    
        kinde_client = user_clients[user_id]
        # Ensure the client is authenticated
        if not kinde_client.is_authenticated():
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
        return kinde_client
    

    We are now assigning each client to a dictionary value to save states. Since the sessions aren’t stored natively, we will have to keep track of each user. We are doing this by grabbing the current user’s KindeApiClient instance. If it does not exist, we create a new instance of the KindeApiClient so that we can use the helper functions to determine the various params that we can track like is_authenticated.

    💡 Do note, that if you intend to use this in a production environment you should store session data in a shared data store like Redis, Memcached, or a database to maintain session state across all instances of your application. FastAPI does not provide built-in support for server-side session stores, so you would need to integrate a session management solution that supports distributed environments.

    @app.get("/")
    def read_root(kinde_client: KindeApiClient = Depends(get_kinde_client)):
        # Now this route requires authentication
        return {"Hello": "World"}
    
    @app.get("/items/{item_id}")
    def read_item(item_id: int, q: Union[str, None] = None, kinde_client: KindeApiClient = Depends(get_kinde_client)):
        # This route also requires authentication
        return {"item_id": item_id, "q": q}
    

    Now we simply only have to add kinde_client: KindeApiClient = Depends(get_kinde_client) as the argument for each method we want to require authentication. Any new method going forward will simply only require that above line to add and it will be fully gated without any more fuss with the core application.

  7. Navigate to http://127.0.0.1:8000 and you will now be greeted with a {"detail":"Not authenticated"} message.

    Success!

FastAPI Authorization

Link to this section

Your FastAPI instance is now fully gated. This is fine, but we currently do not have a way for people to authorize in our application.

  1. We will be using the starlette again to manage sessions, but this time it will be used to store and authenticate the cookies from the tokens that Kinde will pass to the application. Another option would be to use OAuth2 module build into the FastAPI library itself to set up a M2M application that utilizes access tokens if you so require for your needs. This method will result in a smoother authentication process, as this will bypass email confirmations, resets, etc. If you need information on how to generate or fetch an access token for M2M needs, that can be found here.

  2. Update the app.py with the following:

    from typing import Union
    from fastapi import FastAPI, Depends, HTTPException, status, Request, APIRouter
    from fastapi.responses import RedirectResponse
    from starlette.middleware.sessions import SessionMiddleware
    from kinde_sdk import Configuration
    from kinde_sdk.kinde_api_client import KindeApiClient, GrantType
    
    import config  # Your configuration file with necessary settings
    
    app = FastAPI()
    app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY)
    
    # Callback endpoint
    router = APIRouter()
    
    # Initialize Kinde client with configuration
    configuration = Configuration(host=config.KINDE_ISSUER_URL)
    kinde_api_client_params = {
        "configuration": configuration,
        "domain": config.KINDE_ISSUER_URL,
        "client_id": config.CLIENT_ID,
        "client_secret": config.CLIENT_SECRET,
        "grant_type": config.GRANT_TYPE,
        "callback_url": config.KINDE_CALLBACK_URL,
    }
    if config.GRANT_TYPE == GrantType.AUTHORIZATION_CODE_WITH_PKCE:
        kinde_api_client_params["code_verifier"] = config.CODE_VERIFIER
    
    # User clients dictionary to store Kinde clients for each user
    user_clients = {}
    
    # Dependency to get the current user's KindeApiClient instance
    def get_kinde_client(request: Request) -> KindeApiClient:
        user_id = request.session.get("user_id")
        if user_id is None:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
        if user_id not in user_clients:
            # If the client does not exist, create a new instance with parameters
            user_clients[user_id] = KindeApiClient(**kinde_api_client_params)
    
        kinde_client = user_clients[user_id]
        # Ensure the client is authenticated
        if not kinde_client.is_authenticated():
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
        return kinde_client
    
    # Login endpoint
    @app.get("/api/auth/login")
    def login(request: Request):
        kinde_client = KindeApiClient(**kinde_api_client_params)
        login_url = kinde_client.get_login_url()
        return RedirectResponse(login_url)
    
    # Register endpoint
    @app.get("/api/auth/register")
    def register(request: Request):
        kinde_client = KindeApiClient(**kinde_api_client_params)
        register_url = kinde_client.get_register_url()
        return RedirectResponse(register_url)
    
    @app.get("/api/auth/kinde_callback")
    def callback(request: Request):
        kinde_client = KindeApiClient(**kinde_api_client_params)
        kinde_client.fetch_token(authorization_response=str(request.url))
        user = kinde_client.get_user_details()
        request.session["user_id"] = user.get("id")
        user_clients[user.get("id")] = kinde_client
        return RedirectResponse(router.url_path_for("read_root"))
    
    # Logout endpoint
    @app.get("/api/auth/logout")
    def logout(request: Request):
        user_id = request.session.get("user_id")
        if user_id in user_clients:
            kinde_client = user_clients[user_id]
            logout_url = kinde_client.logout(redirect_to=config.LOGOUT_REDIRECT_URL)
            del user_clients[user_id]
            request.session.pop("user_id", None)
            return RedirectResponse(logout_url)
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    
    @app.get("/")
    def read_root(kinde_client: KindeApiClient = Depends(get_kinde_client)):
        # Now this route requires authentication
        return {"Hello": "World"}
    
    @app.get("/items/{item_id}")
    def read_item(item_id: int, q: Union[str, None] = None, kinde_client: KindeApiClient = Depends(get_kinde_client)):
        # This route also requires authentication
        return {"item_id": item_id, "q": q}
    

    We are adding basic methods for user authentication through Kinde and storing them in the dictionary we created. We generate new methods that route towards the Kinde auth flow, which enable the registration process and handle the session tokens. The session gets saved and authenticates the local user. If a user logs out, they are logged out from the dictionary value and access is removed.

    A good way to test would be through the utilization of the /docs endpoint, which will generate each endpoint as a full, executable API call that you can use right within the interface. Here you can more easily execute each of the auth functions as well as your sample routes.

    If you authenticate using access tokens from within the application, then you can authorize from here and test out your credentials directly.

From here we can build upon the users by leveraging the helper libraries such as getting feature_flags, properties params, and managing specific user data as you wish.

Congratulations 🎉

Link to this section

We successfully secured our routes using Kinde in a FastAPI-generated environment. This setup leverages the strengths of both platforms: Kinde’s simplicity in authentication processes and FastAPI’s speed and power in API route handling.

Remember to observe good security practices in handling user data and credentials. Regularly update your dependencies and monitor your application for any security advisories.

If you need any assistance with getting Kinde connected reach out to support@kinde.com.

You can also join the Kinde community on Slack or the Kinde community on Discord for support and advice from the team and others working with Kinde.