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
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:
- The webapp redirects to the LOGIN endpoint
- Cognito signs in the user, usually by username+password
- Cognito redirects back to the webapp with a
code
parameter - The webapp makes a request to the TOKEN endpoint with the code and gets the tokens
- 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.