Generating the code challenge for PKCE in OAuth 2

What is the code challenge

For authenticating single-page applications against an OAuth 2 server, the current RFC recommends an authentication code grant with PKCE (Proof Key for Code Exchange). Here's how it works.

When the user initiates an authentication flow, the client should compute a code_verifier. This must be a random, high entropy string between 43 and 128 characters.

Next up, the client computes a code_challenge starting from the code_verifier. This is the result of the following pseudo-code:

code_challenge = base64urlEncode(SHA256(ASCII(code_verifier)))

See also rfc7636, section 4.

The code_challenge must be sent in the first step of the authorization flow.

The code_verifier instead must be sent along the POST request to the authorization server for requesting the final access token.

To generate code_verifier and code_challenge we can employ two different methods, depending on the platform.

Generating code challenge in Node.js

If the single-page application entry point is served by Node.js we can use this platform with the following modules:

  • randomstring
  • crypto (part of the standard library)
  • base64url

The following example illustrates the process. I borrowed this snippet from OAuth 2 in action by Justin Richer:

const randomstring = require("randomstring");
const crypto = require("crypto");
const base64url = require("base64url");

const code_verifier = randomstring.generate(128);

const base64Digest = crypto
.createHash("sha256")
.update(code_verifier)
.digest("base64");

console.log(base64Digest); // +PCBxoCJMdDloUVl1ctjvA6VNbY6fTg1P7PNhymbydM=

const code_challenge = base64url.fromBase64(base64Digest);

console.log(code_challenge); // -PCBxoCJMdDloUVl1ctjvA6VNbY6fTg1P7PNhymbydM

Here we first create a base64 digest of the code_verifier hash. Then we create a base64 encoded url string.

Generating code challenge in the frontend

If the single-page is just a single html with chunks and bundles, we can't rely on Node.js crypto to generate the hash, nor on randomstring, which is 306kB alone.

We can certainly use a dynamic import to load the module on demand, but it still feels a waste. For now, let's pretend we found a random string generator secure and lightweight enough. We call it fictional-random-string-generator.

For the crypto part in the frontend instead, we can use window.crypto.subtle. Let's see the code step by step.

First, we generate the SHA-256 digest (this snippet instead came from my mind):

import randomstring from "fictional-random-string-generator";
import { encode as base64encode } from "base64-arraybuffer";

const code_verifier = randomstring.generate(128);

async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
//
}

Here we call window.crypto.subtle.digest() which takes the desired hashing algorithm, and an ArrayBuffer. We compute the latter with TextEncoder.

Important: window.crypto.subtle is available only over HTTPS, or on http://localhost.

Be aware that these are quite new Web APIs, older browsers don't support them.

Next up, we convert the return value of window.crypto.subtle.digest(), a Promise which resolves to an ArrayBuffer, to its base64 representation with the base64-arraybuffer package:

import randomstring from "fictional-random-string-generator";
import { encode as base64encode } from "base64-arraybuffer";

const code_verifier = randomstring.generate(128);

async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
const base64Digest = base64encode(digest);
// you can extract this replacing code to a function
return base64Digest
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}

Finally, we escape the base64 to a base64url string. We can finally consume this function to obtain a code challenge:

generateCodeChallenge(code_verifier).then(challenge => {
console.log(challenge);
// example:
// -PCBxoCJMdDloUVl1ctjvA6VNbY6fTg1P7PNhymbydM
});

We then send the challenge in the first step of the authorization flow.

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!