DEV Community

Cover image for OAuth 2.1 and PKCE: Secure Flow in SPA Sessions
Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

OAuth 2.1 and PKCE: Secure Flow in SPA Sessions

While the OAuth 2.0 Authorization Code Flow delivers the authorization code to the browser, it carries the risk of this code being intercepted by a malicious client. PKCE (Proof Key for Code Exchange), developed to mitigate this risk, and the accompanying OAuth 2.1 draft standard, significantly enhance security, especially for public clients like Single Page Applications (SPAs). OAuth 2.1 and PKCE establish a more robust structure for a SPA's authentication and authorization processes without using a client secret.

In this article, we will delve into the weaknesses in OAuth 2.0, how PKCE works, the fundamental changes introduced with OAuth 2.1, and how to integrate these mechanisms into a SPA application. Our goal is to explain these approaches, which elevate the security standards of modern web applications, with practical examples.

What Were the Weaknesses of the OAuth 2.0 Authorization Code Flow?

The OAuth 2.0 Authorization Code Flow is a common authorization flow that allows a user to grant an application access to their resources on another service. In this flow, the user is redirected to an authorization server, grants consent, and the authorization server returns an authorization code to the application. The application then uses this code to request an access token.

However, this flow has some significant weaknesses, especially for public clients. Traditionally, a client_secret is used in the authorization code exchange; this is a secret key that authenticates the client. In public clients like browser-based SPAs or mobile applications, this client_secret cannot be stored securely, and the client code is visible to everyone. This situation allows an intercepted authorization code to be used by a malicious client to obtain a token.

⚠️ Storing the Client Secret

It is not possible to store a client_secret in a SPA, as all code running in the browser can be inspected by the client. This leads to the exposure of the client_secret and increases the risk of unauthorized access. In earlier versions of OAuth 2.0, this led to the use of less secure flows like the Implicit Flow for SPAs.

An authorization code interception attack occurs when an attacker steals the authorization code by injecting malicious code into the user's browser or by manipulating the redirect URL. The attacker can then use this code to request an access token via their own server and gain access to the user's resources. The absence or exposure of the client_secret makes this attack much easier for public clients.

What is PKCE (Proof Key for Code Exchange) and How Does It Work?

PKCE (Proof Key for Code Exchange) is an add-on that brings an additional layer of security to the OAuth 2.0 Authorization Code Flow. Its main purpose is to protect public clients (SPAs, mobile applications) that cannot use a client_secret against authorization code interception attacks. PKCE is defined in RFC 7636 and has become a core part of the OAuth 2.1 draft standard.

The working principle of PKCE is quite simple yet effective. At the beginning of the authorization flow, the client generates a code_verifier, which is a single-use, cryptographically secure random string. This code_verifier is then passed through a cryptographic hash function to produce a code_challenge. The authorization request is sent to the authorization server along with this code_challenge.

ℹ️ Generating Code Verifier and Code Challenge

The code_verifier is a randomly generated string between 43 and 128 characters long. The code_challenge is typically derived from the code_verifier using the S256 (SHA256) hash algorithm and then URL-safe base64 encoded.

The authorization server stores the code_challenge and, after user consent, returns the authorization code to the client. When the client requests an access token, it includes the previously generated code_verifier in the token request. The authorization server receives this code_verifier and compares it with the code_challenge it stored. It regenerates a code_challenge from the code_verifier and checks if it matches the original code_challenge. If it does not match, the token request is rejected. This ensures that a malicious client that intercepts the authorization code cannot obtain an access token unless it also possesses the correct code_verifier.

The following Mermaid diagram visualizes an authorization flow secured with PKCE:

Diagram

This flow provides an additional layer of security because the code_verifier is kept only on the client side and is not exposed directly in browser traffic. Thus, even if the authorization code is stolen, the attacker would need to know the code_verifier, which is quite difficult.

What Are the Changes and Security Improvements with OAuth 2.1?

OAuth 2.1 is a draft update to the OAuth 2.0 specification, aimed at closing various security vulnerabilities and consolidating best practices. This new version focuses particularly on enhancing the security of public clients and has either made some flows mandatory or removed them entirely.

One of the most critical changes with OAuth 2.1 is the mandating of PKCE for all authorization code flows. This means that all clients, whether a SPA, a mobile application, or a traditional web application, must use PKCE during authorization code exchange. This requirement provides strong protection against authorization code interception attacks, even in cases where the client_secret cannot be stored securely.

Other significant changes include:

  • Removal of the Implicit Flow: The Implicit Flow, previously recommended for SPAs in OAuth 2.0, returned the access token directly in the URL fragment, posing risks of being saved in browser history and being vulnerable to XSS attacks. With OAuth 2.1, this flow has been completely removed. The only recommended flow for SPAs is now the Authorization Code Flow strengthened with PKCE.
  • Removal of the Resource Owner Password Credentials (ROPC) Flow: This flow, which required the username and password to be sent directly from the client to the authorization server, has been removed due to security risks (e.g., phishing attacks). This flow was never considered suitable for public clients and has been completely abandoned with OAuth 2.1.
  • Removal of Client Secret Requirement for Public Clients: For public clients where the client_secret cannot be stored securely, the use of a client_secret is no longer mandatory. With PKCE becoming mandatory, the need for a client_secret has been eliminated.
  • Refresh Token Rotation and Restrictions: Stricter rules have been introduced regarding the use of refresh tokens. For example, it is recommended that refresh tokens be invalidated after each use and a new refresh token be issued in its place (single-use refresh tokens or rotation). Furthermore, it is emphasized that returned refresh tokens should only be usable by the client that received the initial token and should have a specific lifetime.

These changes aim to raise security standards within the OAuth ecosystem and encourage developers to design more secure applications. When designing the authentication system for the backend of my own side product, I found that the flexibility of such standards and the security benefits they provide allowed me to build a much more robust structure. Especially the secure management and single-use nature of refresh tokens play a critical role in minimizing risk in potential token theft scenarios.

OAuth 2.1 and PKCE Integration in SPA Applications

Correctly integrating OAuth 2.1 and PKCE when developing a Single Page Application (SPA) is vital for ensuring your application's security. This integration can typically be achieved using a JavaScript-based client library or through manual steps. Here are the fundamental steps to integrate the OAuth 2.1 and PKCE flow in a SPA:

  1. Generate code_verifier and code_challenge:
    The application must generate a cryptographically secure code_verifier at the beginning of the authentication flow. Then, a code_challenge is derived from this code_verifier using the SHA256 algorithm. These values should be temporarily stored in the client's memory or session storage until the authorization server returns.

    // Helper function to generate a random string
    function generateRandomString(length) {
        const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        let text = '';
        for (let i = 0; i < length; i++) {
            text += possible.charAt(Math.floor(Math.random() * possible.length));
        }
        return text;
    }
    
    // Helper function to generate SHA256 hash
    async function sha256(plain) {
        const encoder = new TextEncoder();
        const data = encoder.encode(plain);
        return window.crypto.subtle.digest('SHA-256', data);
    }
    
    // Helper function to base64url encode
    function base64urlencode(a) {
        return btoa(String.fromCharCode.apply(null, new Array(...new Uint8Array(a))))
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
            .replace(/=+$/, '');
    }
    
    // Function to generate PKCE values
    async function generatePkce() {
        const codeVerifier = generateRandomString(128); // 43-128 characters
        const hashed = await sha256(codeVerifier);
        const codeChallenge = base64urlencode(hashed);
        return { codeVerifier, codeChallenge };
    }
    
    // Example usage
    // const { codeVerifier, codeChallenge } = await generatePkce();
    // sessionStorage.setItem('code_verifier', codeVerifier);
    
  2. Send Authorization Request:
    When redirecting the user to the authorization server, you need to include the code_challenge and code_challenge_method (typically S256) parameters in the authorization URL. Standard OAuth parameters such as response_type=code, client_id, redirect_uri, and scope should also be included.

    async function redirectToAuthServer() {
        const { codeVerifier, codeChallenge } = await generatePkce();
        sessionStorage.setItem('code_verifier', codeVerifier); // Store code_verifier
    
        const authUrl = new URL('https://your-auth-server.com/oauth/authorize');
        authUrl.searchParams.append('response_type', 'code');
        authUrl.searchParams.append('client_id', 'your-spa-client-id');
        authUrl.searchParams.append('redirect_uri', 'https://your-spa.com/callback');
        authUrl.searchParams.append('scope', 'openid profile email');
        authUrl.searchParams.append('code_challenge', codeChallenge);
        authUrl.searchParams.append('code_challenge_method', 'S256'); //
    
        window.location.href = authUrl.toString();
    }
    
  3. Handle Authorization Code (Callback):
    After user consent, the authorization server will redirect the application to the redirect_uri along with the authorization code. The SPA should retrieve this code from the URL and use it to obtain an access token.

    async function handleAuthCallback() {
        const urlParams = new URLSearchParams(window.location.search);
        const code = urlParams.get('code');
        const error = urlParams.get('error');
    
        if (error) {
            console.error('Authorization error:', error);
            // Display error to the user
            return;
        }
    
        if (code) {
            const codeVerifier = sessionStorage.getItem('code_verifier'); //
            if (!codeVerifier) {
                console.error('PKCE code_verifier not found.');
                // Restart authentication or show error
                return;
            }
            // Use code and codeVerifier for token exchange
            await exchangeCodeForTokens(code, codeVerifier);
            sessionStorage.removeItem('code_verifier'); // Clear after use
        }
    }
    // For example, call this function when the SPA loads
    // if (window.location.pathname === '/callback') {
    //     handleAuthCallback();
    // }
    
  4. Request Access Token:
    The application requests an access token from the authorization server using the received authorization code and the stored code_verifier. This request is typically sent as a POST request to the token endpoint.

    async function exchangeCodeForTokens(code, codeVerifier) {
        const tokenUrl = 'https://your-auth-server.com/oauth/token';
        const params = new URLSearchParams();
        params.append('grant_type', 'authorization_code');
        params.append('client_id', 'your-spa-client-id');
        params.append('code', code);
        params.append('redirect_uri', 'https://your-spa.com/callback');
        params.append('code_verifier', codeVerifier); // Critical for PKCE
    
        try {
            const response = await fetch(tokenUrl, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: params.toString(),
            });
    
            const data = await response.json();
            if (response.ok) {
                console.log('Tokens successfully received:', data);
                // Securely store Access Token and Refresh Token
                // E.g.: saveTokens(data.access_token, data.refresh_token);
                // Redirect user to main page
                // window.history.replaceState({}, document.title, '/');
            } else {
                console.error('Token exchange error:', data);
            }
        } catch (error) {
            console.error('Network error:', error);
        }
    }
    

💡 Using Libraries

In real-world applications, instead of manually implementing these steps, it is safer and reduces the likelihood of errors to use battle-tested libraries such as oidc-client-js or auth0-spa-js. These libraries handle the PKCE flow and token management for you.

By following these steps, you can successfully integrate OAuth 2.1 and PKCE into your SPA application, providing a secure authorization flow without using a client_secret. Especially in the native bridging processes of my own mobile applications or the backend integrations of my custom financial calculators, implementing such standards significantly strengthens the overall security posture of the application.

Token Management and Security Best Practices

After establishing a secure authorization flow with OAuth 2.1 and PKCE, the correct and secure management of the obtained access token and refresh token is of paramount importance. Incorrect token management can expose your application and user data to serious security risks.

Access Token

Access tokens represent the user's authorization to access the resource server and are typically short-lived (5-60 minutes). These tokens are carried in the Authorization header of API requests.

  • Storage Location: In SPAs, storing the access token in the browser's memory (in-memory) is generally the most secure approach. Storing it in localStorage or sessionStorage leaves the token vulnerable to XSS (Cross-Site Scripting) attacks. In an XSS attack, malicious JavaScript code injected by the attacker can easily access the token in localStorage. A token kept in memory is lost when the page is refreshed, which, while requiring an additional refresh mechanism for user experience, is preferred for security.
  • Usage: Sent to the server with every API request. The server must validate the token's validity and permissions.

Refresh Token

Refresh tokens are used to obtain a new access token when access tokens expire. They are generally longer-lived and considered potentially more sensitive, as their theft could lead to continuous generation of new access tokens.

  • Storage Location: Securely storing refresh tokens in SPAs is more complex.
    • HttpOnly Secure Cookie: One of the most secure methods. Storing the refresh token as a cookie with HttpOnly and Secure flags prevents JavaScript from directly accessing this cookie and protects against XSS attacks. However, this approach means that the token exchange request needs to be proxied through a backend API endpoint. The backend retrieves the cookie, communicates with the authorization server, and returns the new access token to the SPA.
    • In-Memory: If your application does not handle very sensitive data and it's acceptable for the session to be completely reset when the browser is closed, you can also keep the refresh token in memory. This is the least persistent but most XSS-resistant method.
  • One-Time Use Refresh Tokens: Another security improvement recommended by OAuth 2.1 is to invalidate refresh tokens after each use and issue a new refresh token in its place. This means that if a refresh token is stolen, it can only be used once, making it harder for an attacker to gain continuous access.

Additional Security Measures

Token management is not limited to storage location. From a general system security perspective, some additional measures should also be taken:

  • Rate Limiting: Rate-limiting requests to the authorization server's token endpoint prevents brute-force attacks and token exploitation attempts. In the backend of my own side product, I have limited requests to authorization services based on specific IP addresses and users. This allows me to receive immediate alerts and activate automatic blocking mechanisms in case of potential malicious attempts.
  • JWS/JWE Validation: If access tokens are in JWT (JSON Web Token) format, the resource server must validate the signatures (JWS - JSON Web Signature) of these tokens. If tokens are encrypted (JWE - JSON Web Encryption), they must be decrypted and their content validated.
  • Nonce Usage: Especially in OpenID Connect (OIDC) flows, the nonce parameter is used to protect against replay attacks. This parameter is generated in the authorization request and returned in the ID token for matching verification.
  • CORS and SameSite Cookies: CORS (Cross-Origin Resource Sharing) policies must be correctly configured for SPAs. Additionally, if cookies are used, the SameSite attribute (Lax or Strict) should be set to provide protection against CSRF (Cross-Site Request Forgery) attacks.

🔥 Avoid Using Local Storage

Storing access tokens and especially refresh tokens in localStorage or sessionStorage makes the application severely vulnerable to XSS attacks. These storage areas can be read by any JavaScript code running in the browser. Security must take precedence over user experience.

Diligently following these security practices is indispensable for protecting user data and the integrity of the application in critical systems such as a production ERP or a financial application. In my experience, the attention given to these details directly impacts the overall security and operational resilience of the system.

Conclusion

The OAuth 2.1 draft standard and PKCE are critical approaches that significantly enhance the security of modern web applications, especially Single Page Applications (SPAs). Addressing the weaknesses of OAuth 2.0 in public clients, this approach offers a practical and robust solution to the problem of securely storing the client_secret with PKCE. With less secure flows like Implicit Flow becoming obsolete and PKCE becoming mandatory, developers are now required to implement more secure authorization flows.

The steps and best practices discussed in this article form the foundation for OAuth 2.1 and PKCE integration in a SPA application. From token management to protection against common attack types like XSS and CSRF, security-focused thinking is essential at every stage. Writing secure code and correctly implementing standards is not just a technical requirement, but also key to earning user trust. I believe that by applying these principles in your next project, you will develop more robust and reliable applications.

Official Resources

- workos.com

Top comments (2)

Collapse
 
topstar_ai profile image
Luis Cruz

I found the explanation of PKCE's working principle particularly insightful, especially the use of code_verifier and code_challenge to prevent authorization code interception attacks. The fact that PKCE is now a core part of the OAuth 2.1 draft standard highlights its importance in securing public clients like SPAs. One aspect I'd like to explore further is how to handle the storage of code_verifier on the client-side, ensuring it's not accessible to malicious scripts, while still allowing for a seamless authorization flow - what strategies have others used to address this challenge?

Collapse
 
merbayerp profile image
Mustafa ERBAY

That is the tricky part: PKCE protects the authorization code exchange, but it does not make the browser itself a trusted environment.

For a pure SPA, the code_verifier has to survive the redirect somehow. sessionStorage is practical, but any JavaScript running in the same origin can potentially read it, so strong XSS defenses still matter.

My preference for higher-risk applications is to move the OAuth transaction state out of the SPA entirely and use a Backend-for-Frontend pattern. The backend keeps the verifier and tokens server-side, while the browser only receives an HttpOnly, Secure, SameSite session cookie.

If a pure SPA is required, I would keep the verifier short-lived, bind it to the corresponding state value, remove it immediately after the callback, enforce strict CSP and dependency hygiene, and avoid persistent browser storage.

So I tend to think of PKCE as protection against authorization-code interception, not protection against a compromised browser runtime. Once arbitrary JavaScript is executing in your origin, the security problem has moved to a different layer.