DEV Community

Cover image for CSRF tokens in Nextjs
Andres RV
Andres RV

Posted on

CSRF tokens in Nextjs

Recently, I was working on a project with Next.js (using TypeScript), where I needed to implement CSRF tokens to secure some validations in the middleware.ts file. During the process, I encountered some issues that I'll share in this post in case it helps someone else.

First, I did some research online, and apparently, the most commonly used library for this purpose is csrf. So, I installed it and tried a quick test in the middleware.ts file (which doesn’t exist by default) to see how it worked:

// src/middleware.ts

import { NextResponse } from 'next/server'
import Tokens from 'csrf'

export async function middleware() {
  const responseNext = NextResponse.next()

  const tokens = new Tokens()
  const secret = tokens.secretSync()
  const token = tokens.create(secret)

  console.log(`🚀🚀🚀 -> token:`, token)

  return responseNext
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}
Enter fullscreen mode Exit fullscreen mode

And immediately, I got an error 🥲

middleware file error

The error is pretty straightforward, but at the time, it took me a while to find the reason and solution. Essentially, the error says that the rndm library is using the assert function in its code, but this function doesn’t exist (according to the error).

After some investigation, I found important information about this:

  • I didn’t install the rndm library, which causes the error. It’s a dependency of the csrf library, which is why the error appears when using the latter.
  • The middleware.ts file in Next.js runs in the edge runtime instead of the Node.js runtime, so it doesn’t support some Node.js APIs, including the assert function.
  • Therefore, the csrf library cannot be used in the middleware.ts file.

Alternative Solution

Once I identified the problem, I started looking for possible alternatives and found a very good one: @edge-csrf/nextjs. It’s a very simple implementation for Next.js to use within the middleware.ts file.

@edge-csrf/nextjs library

⚠️⚠️⚠️ However, while it’s a great and easy-to-use implementation, for my specific problem, I needed more control and customization. So, I had to make some different adjustments to my code.

Custom Solution

Although I wasn’t going to use the @edge-csrf/nextjs library directly, I decided to use its core, which is another library called @edge-csrf/core. This allowed me to create a custom solution to handle CSRF tokens in the middleware.ts file.

For this tutorial, I made a very basic code implementation that does the minimum to create and verify a CSRF token in Next.js middleware. Here’s how it works:

  • The first time the website’s page loads (a GET request), the middleware creates a cookie with the CSRF token and attaches it to the response so that it can be used later.
  • If it’s not the first time the website loads (a GET request), the cookie might already be expired. In that case, a new cookie is also created to replace the expired one.
  • For this tutorial, cookies expire in 30 seconds for quick testing. In a production project, these cookies typically last 1 hour (more or less) depending on your needs.
  • Then, from the frontend of the website, whenever a PUT, PATCH, or DELETE request is made to the API, a header with the CSRF token is attached so it can be verified.
  • When the API receives any PUT, PATCH, or DELETE request, the middleware first checks whether the header attached to the request exists and whether it’s a valid CSRF token. If not, it generates an error response, and the request ends there. But if the token is valid, the middleware allows the request to proceed to the next step to execute the endpoint.
  • For this tutorial, I’m only validating the token for PUT, PATCH, and DELETE requests, and I’m omitting POST requests. Of course, this is fully customizable and can be adapted to your needs.

Code

👇 Some constants.

// src/constants.ts

export const IS_PRODUCTION: boolean = process.env.NODE_ENV === 'production'

export const CSRF_TOKEN_NAME: string = 'csrftoken'

export const ERROR_CODE_INVALID_CSRF: string = 'middleware/invalid-csrf-token'
export const ERROR_CODE_INTERNAL_SERVER: string = 'server/internal-server-error'
Enter fullscreen mode Exit fullscreen mode

👇 My implementation of the @edge-csrf/core library to generate and verify CSRF tokens.

// src/utils/csrfTokens.ts

import {
  createSecret,
  createToken,
  verifyToken,
  atou,
  utoa,
} from '@edge-csrf/core'

const CONFIG = {
  SALT_LENGTH: 49, // Custom number between 1 and 255
  SECRET_LENGTH: 48, // Custom number between 1 and 255
}

const secretUint8Array = createSecret(CONFIG.SECRET_LENGTH)

export async function generateCsrfToken(): Promise<string> {
  const tokenUint8Arr = await createToken(secretUint8Array, CONFIG.SALT_LENGTH)
  const tokenStr = utoa(tokenUint8Arr)
  return tokenStr
}

export async function verifyCsrfToken(token: string): Promise<boolean> {
  const tokenUint8Arr = atou(token)
  const isValid = await verifyToken(tokenUint8Arr, secretUint8Array)
  return isValid
}
Enter fullscreen mode Exit fullscreen mode

👇 My middleware implementation.

  • For each incoming request, if the request is PUT, PATCH, or DELETE, the middleware checks whether the headers contain the CSRF token and if it’s valid. If it’s valid, the request proceeds to the corresponding endpoint in the API. If not, an error response is returned.
  • On the other hand, if the request is GET and the cookie with the CSRF token doesn’t exist (this is the first time visiting the website, or the cookie has expired), a new token is generated and attached as a cookie to the response.
// src/middleware.ts

import { NextResponse, NextRequest } from 'next/server'

import {
  IS_PRODUCTION,
  CSRF_TOKEN_NAME,
  ERROR_CODE_INVALID_CSRF,
} from '@/constants'
import { generateCsrfToken, verifyCsrfToken } from '@/utils/csrfTokens'

export async function middleware(request: NextRequest) {
  const responseNext = NextResponse.next()

  if (
    request.method === 'PUT' ||
    request.method === 'PATCH' ||
    request.method === 'DELETE'
  ) {
    const invalidCsrfTokenResponse = NextResponse.json(
      { message: ERROR_CODE_INVALID_CSRF },
      { status: 403 },
    )

    try {
      const csrfRequestToken = request.headers.get(CSRF_TOKEN_NAME) ?? ''
      const isTokenValid = await verifyCsrfToken(csrfRequestToken)

      if (!isTokenValid) {
        return invalidCsrfTokenResponse
      }
    } catch (error) {
      console.error(error)
      return invalidCsrfTokenResponse
    }
  } else if (
    request.method === 'GET' &&
    !request.cookies.has(CSRF_TOKEN_NAME)
  ) {
    try {
      const csrfResponseToken = await generateCsrfToken()
      responseNext.cookies.set(CSRF_TOKEN_NAME, csrfResponseToken, {
        sameSite: 'lax',
        httpOnly: false, // The cookie needs to be accessible from JS in the frontend
        secure: IS_PRODUCTION,
        maxAge: 30, // 30 seconds for this tutorial example
      })
    } catch (error) {
      console.error(error)
    }
  }

  return responseNext
}

export const config = {
  // This matcher allows filtering middleware to run on specific paths.
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico, sitemap.xml, robots.txt (metadata files)
     */
    '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}
Enter fullscreen mode Exit fullscreen mode

👇 PUT /login endpoint, which for this tutorial only returns a JSON; but it’s executed only if the middleware allows the request based on the CSRF token validation.

// src/app/api/login/route.ts

import { NextResponse, NextRequest } from 'next/server'

import { ERROR_CODE_INTERNAL_SERVER } from '@/constants'

export async function PUT(request: NextRequest) {
  try {
    // Fake login response.
    const responsePayload = {
      message: 'You are now logged in',
    }
    return new Response(JSON.stringify(responsePayload), { status: 200 })
  } catch (error) {
    console.error(error)

    return NextResponse.json(
      { message: ERROR_CODE_INTERNAL_SERVER },
      { status: 500 },
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

👇 An action to delete the cookie manually if needed.

// src/actions/deleteCsrfCookieAction.ts
'use server'

import { cookies } from 'next/headers'

import { CSRF_TOKEN_NAME } from '@/constants'

export default async function deleteCsrfCookieAction(): Promise<void> {
  try {
    const cookieStore = await cookies()
    const csrfCookie = cookieStore.get(CSRF_TOKEN_NAME)

    if (csrfCookie) {
      cookieStore.delete(CSRF_TOKEN_NAME)
    }
  } catch (error) {
    console.error(error)
  }
}
Enter fullscreen mode Exit fullscreen mode

👇 Finally, the frontend code that makes requests to the API.

  • For the PUT request, it first retrieves the CSRF token from the cookie (if it exists) and attaches it as a header in the request so that the middleware can validate it upon receiving the request.
  • There’s also a function to generate a new cookie, which simply deletes the current cookie (if it still exists) and generates a new one by refreshing the page, as the middleware will create a new cookie if it doesn’t find one in the GET request.
// src/app/components/Login/Login.tsx
'use client'

import { useCallback, useState, Suspense } from 'react'
import { useRouter } from 'next/navigation'

import { CSRF_TOKEN_NAME } from '@/constants'
import getCookie from '@/utils/getCookie'
import deleteCsrfCookieAction from '@/actions/deleteCsrfCookieAction'

function BaseComponent() {
  const router = useRouter()

  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [includeToken, setIncludeToken] = useState<boolean>(false)
  const [resultMessage, setResultMessage] = useState<string>('')

  const handleLogin = useCallback(async () => {
    try {
      setResultMessage('')
      setIsLoading(true)
      const csrfTokenCookie = getCookie(CSRF_TOKEN_NAME)
      const sessionRes = await fetch('/api/login', {
        method: 'PUT',
        headers: {
          [CSRF_TOKEN_NAME]: includeToken ? csrfTokenCookie : '',
        },
      })
      const sessionData = await sessionRes.json()
      setResultMessage(sessionData?.message)
    } catch (error) {
      console.error(error)
      setResultMessage('Unexpected error occurred.')
    } finally {
      setIsLoading(false)
    }
  }, [includeToken])

  const generateNewCookie = useCallback(async () => {
    try {
      setResultMessage('')
      await deleteCsrfCookieAction() // Delete previous cookie if still exists.
      router.refresh() // Sufficient to generate a new cookie because of the middleware.
    } catch (error) {
      console.error(error)
    }
  }, [router])

  return (
    <div className='border border-[#1d9bf0] rounded-xl p-5 text-lg'>
      <div>
        <input
          type='checkbox'
          id='send_token'
          checked={includeToken}
          onChange={(e) => {
            setResultMessage('')
            setIncludeToken(e.target.checked)
          }}
        />
        <label htmlFor='send_token' className='ml-2 cursor-pointer'>
          {`Send token in request`}
        </label>
      </div>

      <div className='pt-3'>
        <button
          type='button'
          onClick={handleLogin}
          disabled={isLoading}
          className='py-1 px-4 rounded bg-[#1d9bf0] font-medium min-w-56 w-full'
        >
          {includeToken ? 'Request (with token)' : 'Request (without token)'}
        </button>
      </div>

      <div className='pt-3'>
        <button
          type='button'
          onClick={generateNewCookie}
          disabled={isLoading}
          className='py-1 px-4 rounded bg-[#1d9bf0] font-medium min-w-56 w-full'
        >
          {'Generate new cookie'}
        </button>
      </div>

      <div className='pt-3 text-[#1d9bf0] font-semibold'>{`RESULT: '${resultMessage}'`}</div>
    </div>
  )
}

export default function Login() {
  return (
    <Suspense>
      <BaseComponent />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

👇 And the main entry page.

// src/app/page.tsx

import Login from '@/components/Login/Login'

export default function Home() {
  return (
    <main className='p-2 text-lg leading-5 sm:pt-10'>
      <div className='border border-[#1d9bf0] rounded-xl p-5 max-w-md mx-auto'>
        <Login />
      </div>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Demo

You can run the project locally or try the live demo. Open the network tab in the browser’s developer tools to see the response:

✅ When the header contains a valid token.

valid token response

❌ When the header doesn’t contain the token or the token is invalid.

invalid token response


Conclusion

If, like me, you want to implement CSRF tokens in Next.js and the standard solution doesn’t work for you or you want more control over the middleware, this can be a viable option or a base example to create your own implementation. I hope it helps!

Links

🚀 Live Demo

💻 GitHub Repo

Top comments (0)