Authentication with PKCE and vanilla JavaScript
Finally you got rid of user management in your application since your organisation has standardized on an IdP. There are plenty to choose from. I usually develop using a Keycloak IdP before I test it with the target IdP (and yes: Azure Entrada tries to be different).
So it's time to adjust your SPA to use the IdP. The admins tell you, you need to use PKCE, part of the OIDC specification
Where is the fun in ready made?
A quick search on npmjs shows, the topic is popular and AWS takes the crown. But for deeper understanding, let's roll our own.
Prerequisites
There are just four items you need. The first three are provided by your IdP admins, the forth one you have to provide to them.
- The URL of your IdP, the full path where
.well-known
can be found. That's usually the root, but does differ for Entrada or Keycloak - The issuer. That typically is the URL, except for Entrada
- The clientId. A String, usually opaque
- The callback urls. Pick those wisely. Typically you want three:
http://localhost:3000/myApp
,https://localhost:8443/myApp
andhttps://final.place.com/myApp
The first two enable you to test your application locally with both http and https
Step 1 - Generate codeVerifier and codeChallenge
They are used to secure the call in the PKCE flow
const generateCodeVerifier = (length = 128) => {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let result = '';
const randomValues = new Uint8Array(length);
window.crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
result += charset.charAt(randomValues[i] % charset.length);
}
return result;
};
const generateCodeChallenge = async (codeVerifier) => {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
};
const base64UrlEncode = (buffer) => {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
Step 2 - Query the IdP's .well-known/openid-configuration
We will retrieve the token endpoints and the issuer.
const getIdp = async (url) => {
const response = await fetch(url);
const json = await response.json();
return json;
};
Step 3 - Creating the URL for triggering login
const loginUrl = (config, codeChallenge, state) => {
const params = new URLSearchParams({
client_id: 'yourClientId',
redirect_uri: 'http://localhost:3000',
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: 'openid email',
state: state
});
return `${config.authorizationEndpoint}?${params}`;
};
Step 4 - Putting it together, trigger login
const configUrl = `https://your.keycloak/realms/demo/.well-known/openid-configuration`;
const codeVerifier = generateCodeVerifier();
const state = generateCodeVerifier(32);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const config = await getIdp(configUrl);
window.location.href = loginUrl(config, codeChallenge, state);
Step 5 - Processsing the return
At our callback location we can collect code
, iss
and state
to then exchange the code
for tokens.
const tokenFromCode = async () => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const iss = urlParams.get('iss');
const state = urlParams.get('state');
const tokenResponse = await fetch(token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'yourClientId',
code,
redirect_uri: 'http://localhost:3000',
code_verifier: codeVerifier,
state
})
});
if (!tokenResponse.ok) {
throw new Error(`IdP login failed ${tokenResponse.status}:${tokenResponse.statusText}`);
}
const json = await tokenResponse.json();
return json;
};
The returned json
contains the access_token
and, depending on the IdP the refresh_token
. Store them local and use them for Bearer authentication.
As usual YMMV. You want to add error handling and loggin.
Posted by Stephan H Wissel on 01 September 2025 | Comments (0) | categories: JavaScript WebDevelopment