SPA Needs Secure Login? Use Authorization Code Flow with PKCE

Published on:

Single-page applications run in the browser.

That means they cannot safely store a client secret.

For a browser-based app, the secure pattern is:

Authorization Code Flow with PKCE.

PKCE stands for Proof Key for Code Exchange.

How This Differs from the Web App Flow

The previous trip used Authorization Code Flow for a server-side web app.

That app could keep a client_secret on the server and use it when exchanging the authorization code for tokens.

A SPA is different.

Its code runs in the user’s browser. Anything placed in the SPA can be inspected, copied, or modified by the user.

That is why a SPA must not use a client secret.

Instead, the SPA uses PKCE:

  • the SPA creates a random code_verifier
  • it sends only a hashed code_challenge during sign-in
  • later it proves possession of the original code_verifier
  • Microsoft Entra ID issues tokens only if the verifier matches the challenge

So the purpose is similar:

Sign in user -> get authorization code -> exchange code for tokens -> call API

But the protection mechanism is different:

Server-side web app: client secret protects the code exchange
SPA: PKCE protects the code exchange

This pattern is used when:

  • a user is present
  • the app runs in the browser
  • no client secret can be protected
  • authentication is handled by Microsoft Entra ID
  • the app needs delegated access to an API

In this example:

  • CloudTrips SPA is the browser application
  • CloudTrips API is the protected resource
  • Employee is the signed-in user

Authentication is done with OAuth 2.0 Authorization Code Flow with PKCE. Authorization is controlled with delegated permissions.

Architecture

Employee -> CloudTrips SPA -> Microsoft Entra ID -> CloudTrips API

The SPA redirects the browser to Microsoft Entra ID.

The SPA receives an authorization code.

The SPA exchanges the code for tokens using a PKCE code verifier.

No client secret is used.

PKCE Verifier and Challenge

PKCE works by splitting one secret into two moments.

First, the SPA creates a long random string:

code_verifier=RANDOM_SECRET_CREATED_IN_THE_BROWSER

The SPA keeps this value locally for the current sign-in attempt.

Then the SPA hashes it with SHA-256 and base64url-encodes the result:

code_challenge=BASE64URL(SHA256(code_verifier))
code_challenge_method=S256

The browser sends the code_challenge to Microsoft Entra ID in the authorize request.

The browser does not send the code_verifier yet.

Later, when the SPA receives the authorization code, it sends the original code_verifier to the token endpoint.

Microsoft Entra ID hashes that verifier again and checks whether it matches the earlier code_challenge.

This is the core PKCE proof:

Authorize request: send code_challenge
Token request: send code_verifier
Entra check: SHA256(code_verifier) must match code_challenge

Create CloudTrips API App

First, create the resource application.

Go to:

Entra ID > App registrations > New registration

Create an application:

Name: CloudTrips-API

This app registration represents the API that will receive and validate access tokens.

Microsoft Entra app registration creation page with CloudTrips API entered as the application name

Expose API

Inside CloudTrips-API, open:

Expose an API

Set the Application ID URI:

api://<cloudtrips-api-application-id>

This identifier becomes the audience for access tokens issued to the CloudTrips API.

CloudTrips API App registration on the Expose an API page with the Application ID URI configured

Create Delegated Scope

Inside CloudTrips-API, add a delegated scope:

Expose an API > Add a scope

Example:

Scope name: Trips.Read
Who can consent: Admins and users
Admin consent display name: Read CloudTrips trips
User consent display name: Read your CloudTrips trips
State: Enabled

The CloudTrips API can later check whether the incoming token contains this scope.

Add a scope form for CloudTrips API showing Trips.Read and consent settings

Create CloudTrips SPA

Now create the browser client application.

Go to:

Entra ID > App registrations > New registration

Create an application:

Name: CloudTrips-SPA
Supported account types: Single tenant
Redirect URI platform: Single-page application
Redirect URI: http://localhost:5173/auth/callback

The redirect URI is where Microsoft Entra ID sends the browser after the user signs in.

For production, use the real SPA URL.

Microsoft Entra app registration creation page with CloudTrips SPA and a single-page application redirect URI

Check SPA Authentication Settings

Inside CloudTrips-SPA, open:

Authentication

Confirm that the platform is:

Single-page application

And that the redirect URI is:

http://localhost:5173/auth/callback

Do not create a client secret for the SPA.

A SPA is a public client. Any secret placed in browser code can be read by users.

CloudTrips SPA Authentication page showing the single-page application redirect URI

Add API Permission

Go to:

App registrations > CloudTrips-SPA > API permissions

Then select:

Add a permission

Choose:

My APIs > CloudTrips-API > Delegated permissions > Trips.Read

This allows the SPA to request an access token for the CloudTrips API on behalf of the signed-in user.

CloudTrips SPA API permissions page showing the CloudTrips API delegated Trips.Read permission selected

If your tenant requires administrator approval, click:

Grant admin consent

CloudTrips SPA API permissions page after admin consent is granted

Authorization Request with PKCE

At runtime, the browser is redirected to:

GET https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize

Request parameters include:

client_id=CLOUDTRIPS_SPA_CLIENT_ID
response_type=code
redirect_uri=http://localhost:5173/auth/callback
scope=openid profile api://<cloudtrips-api-application-id>/Trips.Read
state=<random-state-value>
code_challenge=<hashed-code-verifier>
code_challenge_method=S256

The important PKCE values are:

Value Meaning
code_verifier secret random value created by the SPA and kept locally until the token request
code_challenge hashed value derived from the verifier and sent in the authorize request
code_challenge_method S256, meaning SHA-256 was used to create the challenge

Microsoft Entra ID stores the challenge with the authorization code.

At this point Microsoft Entra ID has seen the challenge, but it has not seen the verifier.

Microsoft Entra sign-in page for the SPA user

Callback with Authorization Code

After the user signs in, Microsoft Entra ID redirects the browser back to:

http://localhost:5173/auth/callback

The callback contains an authorization code.

The SPA checks the state value and then prepares the token request.

The authorization code by itself is not enough.

The SPA must redeem it together with the same code_verifier that was created before the redirect.

Browser developer tools or callback page showing the SPA callback with an authorization code

Exchange Code with Code Verifier

The SPA sends the authorization code to Microsoft Entra ID:

POST https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token

Request body:

client_id=CLOUDTRIPS_SPA_CLIENT_ID
grant_type=authorization_code
code=AUTHORIZATION_CODE
redirect_uri=http://localhost:5173/auth/callback
code_verifier=ORIGINAL_RANDOM_CODE_VERIFIER
scope=openid profile api://<cloudtrips-api-application-id>/Trips.Read

There is no client_secret.

Microsoft Entra ID hashes the submitted code_verifier and compares it with the code_challenge from the authorization request.

If they match, Entra issues tokens.

If the verifier is missing, changed, or from another browser session, the token request fails.

Browser developer tools showing the token request with grant_type authorization_code and code_verifier but no client_secret

Access Token Claims

The SPA receives an access token for the CloudTrips API.

Decode the access token.

Important claims:

Claim Meaning
aud API that should accept the token
scp Delegated permissions granted to the SPA
sub Stable user identifier for this app
tid Tenant ID
iss Token issuer

For this trip, the important proof is:

{
  "aud": "api://<cloudtrips-api-application-id>",
  "scp": "Trips.Read"
}

Decoded access token showing CloudTrips API audience and Trips.Read delegated scope

Why PKCE Matters

Without PKCE, anyone who steals the authorization code could try to redeem it.

With PKCE, the authorization code is bound to the original code_verifier.

That means the token endpoint requires proof that the same browser flow that started the sign-in is also redeeming the code.

Key Difference from Server-Side Web App Flow

Topic Server-side web app SPA with PKCE
Client type Confidential Public
Client secret Yes No
Token exchange Server side Browser/public client
Protection Client secret PKCE code verifier
Common redirect URI /signin-oidc /auth/callback

Enterprise Note

Real enterprise environments usually have multiple stages:

  • DEV
  • TEST or STAGE
  • PROD

Use separate app registrations per environment when redirect URIs, API audiences, or consent boundaries differ.

Example:

CloudTrips-SPA-DEV
CloudTrips-SPA-TEST
CloudTrips-SPA-PROD

CloudTrips-API-DEV
CloudTrips-API-TEST
CloudTrips-API-PROD

This provides:

  • isolated redirect URIs
  • clearer token audiences
  • safer production consent boundaries
  • cleaner auditing and troubleshooting