How to secure the Cognito login flow with a state nonce and PKCE

Prevent CSRF and redirect interception attacks against an OAuth 2.0 login flow

Author's image
Tamás Sallai
7 mins

Cognito authorization code flow

The authorization code flow in Cognito (and in OAuth 2.0) when there is no client secret generated requires several steps but otherwise it's straightforward:

  1. The webapp redirects to the LOGIN endpoint
  2. Cognito signs in the user, usually by username+password
  3. Cognito redirects back to the webapp with a code parameter
  4. The webapp makes a request to the TOKEN endpoint with the code and gets the tokens
  5. The webapp uses the tokens to make requests to the backend

In this article, we'll look into what attacks are possible against this flow and how to prevent them. You'll learn about using a state nonce to prevent CSRF attacks that can happen in some cases and the PKCE (Proof Key for Code Exchange) mechanism recommended by OAuth to prevent redirect interception attacks.

CSRF

The LOGIN endpoint requires only a few parameters:

  • response_type=code
  • client_id
  • redirect_uri

These are all public information as all the users use the same values. Because of this, if a user is on a page that is controlled by the attacker then the attacker can redirect them to a valid login URL. This login endpoint might not even prompt the user to sign in as the AUTHORIZATION endpoint in Cognito will simply redirect with a valid code if the user has logged in recently. Because of this, the attacker might be able to sign in the user to the webapp without a single click required.

The attacker has no control over the redirect_uri parameter as there is a list of allowed values configured for the user pool. So there is no vulnerability that allows the attacker to change that.

But there is an optional argument in the login flow that the attacker controls: the state parameter. This is a value that Cognito returns to the webapp unchanged if it was provided in the LOGIN redirect.

By itself, the state is not vulnerable to any attacks. But the webapp might implement custom logic that uses this value that an attacker might be able to exploit.

Imagine that a CRM app handles customer accounts and it is implemented in a way that automatically retries the failed operation if the access tokens are expired. For example, an admin might want to delete a customer account but the backend returns an error saying the browser needs to relogin. The webapp redirects to Cognito that does the login, then the webapp gets new tokens, then retries the operation with the new credentials. The easiest way to implement this is to add the failed operation to the state so that the webapp can know what operation to retry.

Attack

This implementation opens a way for an attacker to trick a user to send arbitrary requests to the backend. This is called CSRF (Cross-Site Request Forgery) attack. Here's how it works:

Even without such drastic consequences, the webapp should prevent generating tokens outside a legitimate login attempt.

Defense

To implement a defense against CSRF attacks, the webapp needs to generate and store a nonce (randomly generated value used only once) and use that as the state. Then, when Cognito redirects back to the webapp it needs to compare the stored and the received state values and throw an error if they don't match.

With this security mechanism an attacker can not log in the victim.

Redirect interception

The other security problem comes when Cognito redirects to the webapp with the code parameter. The next step is to use the TOKEN endpoint to exchange this authorization code for the access tokens. This call requires these arguments:

  • grant_type=authorization_code
  • client_id
  • redirect_uri
  • code

All but the code is public information as they are the same for every user. Because of this, anyone who has a valid code can get valid tokens.

Again, an attacker can not control where Cognito redirects the user after login as there is a list of allowed destinations configured for the user pool. But in many cases it's possible to capture the request. For example, a mobile app can register a handler for that specific URL or the backend logs these requests to an insecure place. In both cases, an attacker can read the code parameter and then get valid tokens.

Defense with PKCE

PKCE (Proof Key for Code Exchange) is an OAuth 2.0 extension to secure the redirect. This is similar to the state parameter but it's enforced by the TOKEN endpoint. The webapp sets a code_challenge when it redirects to the LOGIN endpoint. The code that Cognito generates is tied to this challenge and requires a code_verifier parameter in addition to the code for the tokens.

Even if the attacker can intercept the redirect and gets the code parameter, it is useless without the codeVerifier generated by the webapp.

Implementation

Let's see how the webapp can implement both defenses!

Redirect to LOGIN

Before the redirect, it needs to generate two random values:

const redirectToLogin = async () => {
	const state = await generateNonce();
	const codeVerifier = await generateNonce();
	sessionStorage.setItem(`codeVerifier-${state}`, codeVerifier);
	const codeChallenge = base64URLEncode(await sha256(codeVerifier));
	window.location = `${cognitoLoginUrl}/login?response_type=code&client_id=${clientId}&state=${state}&code_challenge_method=S256&code_challenge=${codeChallenge}&redirect_uri=${window.location.origin}`;
};

The state and the codeVerifier are both SHA256 hashes with enough entropy. This hashing is not required but it makes sure that the values contain only letters and numbers and those can be safely added to URIs without escaping.

Then it needs to store both values somewhere. The localStorage is an obvious place, but even the sessionStorage is sufficient. Cognito redirects in the same tab and that is within the same session. Also, it makes sure that the storage is not accidentally accumulate unneeded data.

sessionStorage.setItem(`codeVerifier-${state}`, codeVerifier);

Then the LOGIN endpoint needs the state, the code_challenge_method and the code_challenge on top of the required parameters. The state does not require any transformation, but the code_challenge must be an encoded hash of the codeVerifier.

The utility function for the above code:

const sha256 = async (str) => {
	return await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
};

const generateNonce = async () => {
	const hash = await sha256(crypto.getRandomValues(new Uint32Array(4)).toString());
	// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
	const hashArray = Array.from(new Uint8Array(hash));
	return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
};

const base64URLEncode = (string) => {
	return btoa(String.fromCharCode.apply(null, new Uint8Array(string)))
		.replace(/\+/g, "-")
		.replace(/\//g, "_")
		.replace(/=+$/, "")
};

Exchanging the code for the tokens

When Cognito redirects with the code, the webapp needs to recover the state and the codeVerifier first:

const state = searchParams.get("state");
const codeVerifier = sessionStorage.getItem(`codeVerifier-${state}`);
sessionStorage.removeItem(`codeVerifier-${state}`);
if (codeVerifier === null) {
	throw new Error("Unexpected code");
}

It is important to bail out if the storage does not contain the state. In that case, the webapp should not retry the login but instead show the error to the user.

To get the tokens, the webapp needs to add the code_verifier to the TOKEN request:

const res = await fetch(`${cognitoLoginUrl}/oauth2/token`, {
	method: "POST",
	headers: new Headers({"content-type": "application/x-www-form-urlencoded"}),
	body: Object.entries({
		"grant_type": "authorization_code",
		"client_id": clientId,
		"code": searchParams.get("code"),
		"code_verifier": codeVerifier,
		"redirect_uri": window.location.origin,
	}).map(([k, v]) => `${k}=${v}`).join("&"),
});
if (!res.ok) {
	throw new Error(await res.json());
}
const tokens = await res.json();

This provides the PKCE value along with the code and Cognito returns the tokens.

Conclusion

OAuth simplifies the login flow a lot but there are all sorts of optional best practices to make sure it's secure against certain attacks. In this article we looked into two: CSRF and redirect interceptions and how to defend against both.

September 7, 2021