Skip to content

Combining Next.js and NextAuth with a FastAPI backend

Posted on:July 17, 2023 at 03:08 PM

A recent project I worked on required me to combine a Next.js frontend which used NextAuth for authentication, with a FastAPI backend.

For those unfamiliar, NextAuth is a great library that makes it very easy to add authentication to your Next.js applications. It has built in support for a ton of different OAuth2 providers, and among other things handles generating, signing, and encrypting JWTs for you.

FastAPI is a brilliant python framework for building APIs quickly and easily. It has support for a lot of different auth methods out of the box, but I could not find any prebuilt functionality that would have allowed me to use the tokens generated by NextAuth directly.

In this post we will explore how NextAuth generates its tokens, and what we need to do to decode and use these on the FastAPI side.

For those only interested in the implementation, please take a look at my GitHub! If you want to know what is going on under the hood, please read on!

Table of contents

Open Table of contents

How NextAuth generates its tokens

One important thing to know is that NextAuth does not use standard JWT by default. It uses JWE (JSON Web Encryption) to not only sign, but encrypt the tokens as well. For the purposes of this blog post just being aware of this fact is sufficient, but if you are interested in more information about JWE there is an excellent explanation here.

On a high level, NextAuth talks to the OAuth provider you configured, authenticates the user, fetches some information about said user, and stuffs that information into an encrypted JWT. (There is a bit more to it than that, but this post is not about OAuth. A halfway decent explanation would be at least a whole post by itself).

But how does the token get encrypted? The JWE standard allows many different types of encryption, including hybrid encryption which uses asymmetric cryptography to encrypt a key used for symmetric encryption of the actual contents of the token (symmetric encryption is generally less resource intensive to decrypt, and more suitable for larger pieces of data).

To see how NextAuth does it, we need to dive into the code a bit. After some digging around, I found the file that contains the functions we are looking for here. Immediately we can see the following code:

/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */
export async function encode(params: JWTEncodeParams) {
  const { token = {}, secret, maxAge = DEFAULT_MAX_AGE } = params;
  const encryptionSecret = await getDerivedEncryptionKey(secret);
  return await new EncryptJWT(token)
    .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
    .setIssuedAt()
    .setExpirationTime(now() + maxAge)
    .setJti(uuid())
    .encrypt(encryptionSecret);
}

with the line .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) containing the information we were looking for. alg: "dir" means we are directly encrypting the contents using symmetric encryption, and enc: "A256GCM" meaning the encryption algorithm used is AES in Galois/Counter Mode using a 256-bit key.

Since we know we are using symmetric encryption directly, we just need to find the right key to be able to decrypt the encrypted tokens, which we can see is derived by NextAuth from the secret specified in the environment variable called NEXTAUTH_SECRET. It does this using the HKDF key derivation function. The relevant code for this is here:

async function getDerivedEncryptionKey(secret: string | Buffer) {
  return await hkdf(
    "sha256",
    secret,
    "",
    "NextAuth.js Generated Encryption Key",
    32
  );
}

If we look up the library the hkdf function comes from (hint: import hkdf from "@panva/hkdf") We can see that the signature for this function is hkdf(digest, ikm, salt, info, keylen), so with that, we know how to derive the key we need to decrypt the token in python / FastAPI later.

How NextAuth stores the tokens

In dev mode, NextAuth stores its JWTs in HTTPOnly cookies named next-auth.session-token, or split up into next-auth.session-token.{0-n} cookies if the size of the token exceeds the max cookie size of 4096 bytes.

Screenshot of cookies stored in firefox

When running in production, NextAuth uses secure HTTPOnly cookies, which offer additional security by restricting the cookies to secure channels only (like HTTPS). The cookie names also get prefixed with __Secure-.

CSRF Protection

You may have noticed the csrf-token cookie in the above screenshot, which is used for cross-site request forgery protection. CSRF attacks allow a malicious actor to perform unwanted actions on behalf of an authenticated user. Simply said, if your browser has a valid JWT in a cookie that authenticates you as a user for the site yourcool.site, and an attacker somehow manipulates you into navigating to a link such as https://yourcool.site/api/deletemyaccountforever, your browser will happily send over your JWT cookie to the api and nuke your account.

This is obvioulsy a bad thing, so NextAuth implements CSRF prevention using a signed double submit cookie.

The double submit cookie technique involves generating a (cryptographically secure) random value, putting it in a cookie, and for each request, putting the same value into another channel of communication such as the headers of the request or in a hidden form field. The idea here is that an attacker will not know this random value, and even though they might be able to manipulate a user into sending a request somewhere with this value attached, they will be unable to put this value into the second channel, and the server can reject the request.

Now, there are ways to defeat this technique, so NextAuth uses a slightly more advanced version of this technique called “signed double submit cookie” which involves the server calculating a hash of the random value with a secret only known to the server concatenated to it. This way, we can verify that the CSRF value is set by the server, and is not controlled by an attacker. Here’s the relevant code in NextAuth:

const csrfToken = randomBytes(32).toString("hex");
const csrfTokenHash = createHash("sha256")
  .update(`${csrfToken}${options.secret}`)
  .digest("hex");
const cookie = `${csrfToken}|${csrfTokenHash}`;

return { cookie, csrfToken };

NextAuth generates a hex string of 32 random bytes, concatenates it with a secret, and hashes it. The value then set in the cookie is equivalent to the token and its hash concatenated with a | as a separator between them.

To validate the CSRF token, we can separate the token and its hash by splitting on | on the value in the cookie, verify that the token was indeed set by the server by calculating the hash in the same way and checking if it matches, and if that’s all good, we can compare the token in the cookie with the value set in the other channel. If they match, we can be reasonably sure the request is legit!

Using the tokens in FastAPI

FastAPI has a powerful dependency injection system that allows you to specify “dependencies” that you can use on routes that you define. This allows us to share logic and reuse code, so perfect for a function that decrypts and verifies our encrypted JWT.

But first, we need to figure out how to decrypt the token in python. Like any code that deals with cryptography, you don’t want to hand roll things. Ever. Period. (Unless you’re like an actual cryptographer and it is your job to write these things :) )

So, we need to find a library that decrypts JWTs for us. After some searching, python-jose seems like a good choice. It supports AES256GCM in “dir” mode, so exactly what we need to decode the tokens generated by NextAuth.

Looking at the examples in the docs, decrypting an encrypted JWT only takes two arguments:

>>> from jose import jwe
>>> jwe.decrypt('eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4R0NNIn0..McILMB3dYsNJSuhcDzQshA.OfX9H_mcUpHDeRM4IA.CcnTWqaqxNsjT4eCaUABSg', 'asecret128bitkey')
'Hello, World!'

An encrypted payload, and a key.

From our previous dive into the NextAuth code, we know that the key is derived from NEXTAUTH_SECRET using HKDF. To get the derived key in python, we can take the cryptography library which implements HKDF:

from cryptography.hazmat.primitives.kdf.hkdf import HKDF

def derive_key(secret: str, length: int, salt: bytes, algorithm, context: bytes) -> bytes:
    hkdf = HKDF(
        algorithm=algorithm,
        length=length,
        salt=salt,
        info=context
    )
    return hkdf.derive(bytes(secret, "ascii"))

and call it like this:

key = derive_key(
            secret=secret,
            length=32,
            salt=b"",
            algorithm=hashes.SHA256(),
            context=b"NextAuth.js Generated Encryption Key"
        )

To get a derived key equivalent to the one derived by NextAuth.

Now that we have the key, we just need to grab the token from the request, and then we can decode it and turn it into a python dict like this:

decrypted_token_string = jwe.decrypt(encrypted_token, key)
token = json.loads(decrypted_token_string)

This covers the basic steps of what is needed to decrypt and verify the token, but to use it in FastAPI we still need to write a dependency for it. This would be a bit too much code to put in this post, but feel free to take a look at my project that implements it all here.

It allows you to specify routes protected by NextAuth tokens like so:

from typing import Annotated
from fastapi import FastAPI, Depends
from fastapi_nextauth_jwt import NextAuthJWT

app = FastAPI()

JWT = NextAuthJWT(
    secret="y0uR_SuP3r_s3cr37_$3cr3t",
)

@app.get("/")
async def return_jwt(jwt: Annotated[dict, Depends(JWT)]):
    return jwt

Anyone calling the / route here will need to have a valid token cookie attached to their request, otherwise access will be denied. Conveniently the contents of the jwt are also available as a dict in the route handler for you, to use the contents of it for whatever you see fit. For example you can wrap the JWT dependency in another dependency that checks if the token contains the right roles / attributes for ABAC or RBAC.

I hope this is useful to someone. The full implementation can be found on my GitHub.

Any contributions are welcome!