OpenID Connect End to End: React, ASP.NET Core, and Keycloak

By Sergey Nosov

16 October 2026

A single-page app runs on its user’s device. It cannot keep a secret, and every token it holds sits in a browser tab. Yet it has to sign a person in and call an API on that person’s behalf. OpenID Connect, built on OAuth 2.0, is the standard way to do both.

This article follows one sign-in from start to finish. A React app signs a user in at Keycloak and calls an ASP.NET Core API. The API checks that the token was issued for it and that the user holds the right role. Aspire starts all three on one machine. Every sample was tested on 16 October 2026 against Keycloak 26.8.1, .NET 10, React 19.3, and oidc-client-ts 3.5. The tests found several defaults that had to change.

The design has a limit, and two current sources draw it. RFC 10017, the IETF’s best current practice for browser-based apps, published in August 2026, describes three architectures. An app that holds its own tokens, like this one, is the least secure. The RFC does not recommend it “for business applications, sensitive applications, and applications that handle personal data.” Microsoft’s ASP.NET Core documentation goes further: “It is no longer recommended to implement OpenID Connect public clients for any web apps.” Both recommend a backend for frontend instead.

What OpenID Connect Adds to OAuth 2.0

OAuth 2.0, published as RFC 6749 in October 2012, lets an app get an access token for an API. The token says what the app may do there. It is not written for the app to read: “The string is usually opaque to the client.” OAuth on its own does not tell the app who signed in.

OpenID Connect fills that gap. Its core specification describes itself as “a simple identity layer on top of the OAuth 2.0 protocol.” The current edition, with two sets of errata folded in, dates from December 2023. In October 2024 the specification was also published as the international standard ISO/IEC 26131:2024. OpenID Connect adds four things an app uses every day:

A sign-in hands the app up to three tokens, and each has one reader:

Mixing them up is a classic mistake. An API that accepts any token signed by the right issuer will accept an ID token as well. The audience check prevents that: the ID token names the app as its audience, and the access token, set up as below, names the API. Sent to the API in this article, the ID token got 401 Unauthorized.

The Flow: Authorization Code with PKCE

RFC 6749 calls a browser app a public client, one of the clients “incapable of maintaining the confidentiality of their credentials.” So the app signs users in with the authorization code flow and Proof Key for Code Exchange (PKCE), and with no client secret. A sign-in takes five steps:

  1. The app creates a random code verifier and stores it in the browser. It sends the browser to Keycloak’s authorization endpoint with its client ID, its registered redirect URI, response_type=code, scope=openid profile, and a random state. It adds a code challenge: the verifier’s SHA-256 hash in base64url form, marked code_challenge_method=S256.
  2. Keycloak signs the user in, or finds the session it already has, and redirects the browser back with a one-time code and the same state.
  3. The app checks the state and posts the code, with the verifier, to the token endpoint.
  4. Keycloak hashes the verifier, compares the result with the challenge from step 1, and returns the three tokens.
  5. The app reads the ID token and calls the API with the access token in the Authorization header.

PKCE is what makes a stolen code worthless. RFC 7636, published in September 2015, defines it to counter code interception: whoever intercepts the code in step 2 lacks the verifier, so the token endpoint refuses the exchange. RFC 9700, the OAuth security best current practice of January 2025, says that public clients “MUST use PKCE.”

Two older flows are out. The implicit flow returned the access token in the redirect URL itself, and RFC 10017 says browser apps “MUST NOT use the Implicit grant type to obtain access tokens.” The password grant has the app collect the user’s password and trade it for tokens. RFC 9700 says the grant “MUST NOT be used,” because it exposes the user’s credentials to the app.

Keycloak enforces PKCE and refuses the password grant once the client is set up as shown below. An authorization request without a code challenge came back with “Missing parameter: code_challenge_method,” and a password grant got “Client not allowed for direct access grants.”

The app sends a state value but no nonce. OpenID Connect requires a nonce only in flows that return the ID token through the browser. In the code flow it is optional, and PKCE already ties the code to the browser that started the sign-in.

The ID token comes straight from the token endpoint over TLS, which OpenID Connect accepts in place of checking its signature. The specification still requires the app to check the issuer, the audience, and the expiry. In version 3.5.0, oidc-client-ts does not; it confirms only that the token names a subject, and the nonce when one was sent. This article’s app accepts that limitation because it uses the ID token only to show the user’s name, and the API never accepts it. It is not complete ID token validation.

My Demo, Then and Now

My react-webapi-oidc repository holds the demo this article grew from: a React app, an ASP.NET Core API that serves a weather forecast, and Keycloak with a ready-made realm. Its two test users show the difference between signing in and being allowed in. The user jane holds the meteorologist role and gets the forecast. The user bob signs in just as well and gets 403 Forbidden.

The demo has been rebuilt once already. Its first version, from 2022, used Create React App, a Kubernetes cluster to run Keycloak, and @react-keycloak/web, a React wrapper around Keycloak’s own JavaScript adapter. By March 2025, two of the three had aged out. The wrapper’s last release dates from October 2020, and its repository now calls it deprecated and unmaintained. The React team deprecated Create React App on 14 February 2025. The cluster was a lot to ask of someone who wanted to try a sign-in.

The March 2025 rebuild replaced the wrapper with oidc-client-ts and react-oidc-context, which are not tied to one identity provider. It moved to Vite, and .NET Aspire replaced the cluster. Clone the repository and start one project, and Aspire starts Keycloak in a container, then the API, then the app.

On 16 October 2026 the repository still ran as published, on .NET 9, Aspire 9.1, and Keycloak 26.1.4. Tests that day confirmed what the demo did well: its realm enforced PKCE, its API refused ID tokens, and bob got 403. They also found the problems that the rest of this article fixes:

The samples below use current versions and fix each of these. They target .NET 10, because support for .NET 9 ends on 10 November 2026.

Setting Up Keycloak

The realm, named demo here, needs two clients and two users. One client stands for the API and the other for the browser app.

The API’s client, weather-api, never signs anyone in, so every flow on it is off. It exists to own the API’s roles. Create a client role named meteorologist on it, and give that role to jane. The demo kept its role on the browser app’s client instead; a role that controls access to the API belongs with the API.

The browser app’s client, weather-spa, takes these settings in the Keycloak 26.8.1 admin console:

Two protocol mappers on the app’s dedicated client scope shape the access token:

The Client ID field of that mapper is easy to leave blank, and the demo did. A blank field copies the in-scope roles of every client into the same claim. With the demo’s settings, a role named meteorologist on an unrelated client was enough to get bob past the API’s role check.

Turning off Full Scope Allowed on the app’s client closes the same hole from the other side: the token then carries only the roles in the client’s scope. Keycloak’s guide marks the switch as deprecated and recommends turning it off in favor of explicit role scope mappings. Either setting alone stopped the leak in testing, but this one also limits where a token works. With Full Scope Allowed on, bob’s token also named the unrelated client and Keycloak’s account service as audiences.

So the app’s dedicated scope needs one role scope mapping. On its Scope tab, where the switch also sits, assign the meteorologist role of weather-api. Without that mapping, jane’s token carried no role at all.

Two realm settings finish the setup. Keycloak already returns a new refresh token with every refresh. Turn on Revoke Refresh Token, and leave Refresh Token Max Reuse at 0, and it also retires the old one. That is rotation, one of the two defenses that RFC 9700 allows for public clients, whose refresh tokens “MUST be sender-constrained or use refresh token rotation.”

Rotation punishes reuse. A second use of a refresh token got “Maximum allowed refresh token reuse exceeded.” Keycloak dropped the app from the user’s session, so even the newest refresh token failed, with “Session doesn’t have required client.” RFC 9700 explains why: the server cannot tell the thief from the client, so it revokes the active refresh token. That “stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant.” The app below runs one renewal at a time per page.

The API: Audience, Role, and Clock Tolerance

The API is a minimal ASP.NET Core app on .NET 10, with Microsoft.AspNetCore.Authentication.JwtBearer 10.0.13. It accepts a token only if Keycloak signed it, it was issued for weather-api, it has not expired beyond a short clock tolerance, and its user holds the meteorologist role:

using System.Security.Claims;

var builder = WebApplication
    .CreateBuilder(args);

string Setting(string key) => builder.Configuration[key]
    ?? throw new InvalidOperationException($"{key} is not set.");

builder.Services
    .AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = Setting("Authority");
        options.Audience = "weather-api";
        options.MapInboundClaims = false;
        options.TokenValidationParameters = new()
        {
            NameClaimType = "preferred_username",
            RoleClaimType = "role",
            ClockSkew = TimeSpan.FromSeconds(30),
        };
    });

builder.Services
    .AddAuthorizationBuilder()
    .AddPolicy("forecast", p =>
        p.RequireRole("meteorologist"));

builder.Services.AddCors(options =>
    options.AddDefaultPolicy(policy => policy
        .WithOrigins(Setting("SpaOrigin"))
        .WithHeaders("Authorization")
        .WithMethods("GET")));

var app = builder.Build();

app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/forecast", (ClaimsPrincipal user) => new
{
    For = user.Identity?.Name,
    Days = new[] { "Cool", "Mild", "Warm" },
})
.RequireAuthorization("forecast");

app.Run();

Authority is the realm’s issuer URL. From it, the handler fetches the discovery document on the first request and takes both the issuer to expect and the keys that check signatures, so the code sets neither. The document must come over HTTPS, since nothing here turns RequireHttpsMetadata off.

Audience keeps out ID tokens and tokens meant for other APIs. RFC 9700 puts that duty on the API: when a token was not meant for it, the API “MUST refuse to serve the respective request.”

The next three settings decide how claims are read. By default, the handler renames incoming claims to long Microsoft claim types. A role claim becomes the type that role checks look for, which is why the demo’s role check worked. MapInboundClaims = false keeps the original names, and the two claim types then say which claim holds the user name and which holds the roles. Without NameClaimType, User.Identity.Name would stay empty.

ClockSkew is the tolerance for a difference between the clocks of Keycloak and the API. Its default is 5 minutes, so a token issued for 5 minutes is honored for up to 10 minutes after it was issued. With the default, the demo’s API accepted a token a minute after it expired. With 30 seconds, a token was accepted 20 seconds after it expired and refused 40 seconds after, with “The token expired at …” in the response. Keep the servers’ clocks synchronized and the tolerance short.

The policy requires the role. A valid token without it gets 403 Forbidden. A token that fails validation gets 401, with an error_description in the WWW-Authenticate header; those descriptions are on in every environment unless IncludeErrorDetails is set to false. A request with no token gets a plain 401.

The CORS policy names the app’s exact origin and allows only what the app sends: GET requests with an Authorization header. Microsoft’s documentation says to call UseCors first and then UseAuthentication and UseAuthorization explicitly. Otherwise ASP.NET Core adds both on its own, ahead of CORS. In testing, an API that called only UseCors still answered preflight requests, but its 401 and 403 responses lacked the Access-Control-Allow-Origin header. The browser hid them from the app, so the renewal after a 401 never ran.

The client credentials article on this site covers two more checks: the RFC 9068 header type for access tokens, which this API leaves to the audience check, and token introspection. The STRIDE article checks record ownership against the token’s subject.

The React App: Sign In, Call, Renew

The app uses oidc-client-ts 3.5.0 for the protocol and react-oidc-context 3.3.1 for React state, on React 19.3 and Vite 8. The first module creates the one object that holds the tokens:

import {
  ErrorResponse,
  UserManager,
  type User,
} from "oidc-client-ts";

export const userManager = new UserManager({
  authority: import.meta.env.VITE_AUTHORITY,
  client_id: "weather-spa",
  redirect_uri: `${location.origin}/`,
  post_logout_redirect_uri: `${location.origin}/`,
  scope: "openid profile",
  // Renewal runs through renew().
  automaticSilentRenew: false,
});

let renewal: Promise<User | null> | undefined;

// One renewal at a time: with
// rotation, a second refresh
// with the same token fails.
export function renew() {
  renewal ??= userManager
    .signinSilent()
    .catch(async (err: unknown) => {
      // Refresh token rejected:
      // drop the user.
      if (
        err instanceof ErrorResponse &&
        err.error === "invalid_grant"
      ) {
        await userManager.removeUser();
      }
      throw err;
    })
    .finally(() => {
      renewal = undefined;
    });
  return renewal;
}

// Renew a minute before expiry.
userManager.events
  .addAccessTokenExpiring(() => {
    // If this fails, the next API
    // call renews after its 401.
    renew().catch(() => {});
  });

For a redirect sign-in, the library accepts only the authorization code flow and adds PKCE unless told not to, so there is nothing to turn on. The scope asks for openid and for profile, which requests profile claims such as the user’s name. Keycloak adds the client’s default scopes, profile and email among them, whatever the app asks for.

The UserManager is created once, at module level, and handed to the provider. Given only its settings, the provider creates a manager of its own inside a React state initializer, which React’s Strict Mode calls twice in development. The demo then ran two managers, each with its own renewal timer, and after a reload both renewed at the same moment with the same refresh token.

Renewal gets a function of its own. The library’s built-in renewal fires a minute before the access token expires, unaware of renewals that API calls start after a 401. When two renewals overlap, both send the same refresh token, and Keycloak refuses one and drops the app from the session, as tests with parallel renewals showed. So the sample turns the built-in renewal off and routes both kinds through renew(). Callers that arrive during a renewal wait for it, so two parallel calls made one refresh request, and both succeeded.

The provider wraps the app:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { userManager } from "./auth";
import App from "./App";

// Remove the code and state from
// the address bar after sign-in.
function cleanUrl() {
  history.replaceState({}, document.title, location.pathname);
}

const root = document.getElementById("root")!;
createRoot(root).render(
  <StrictMode>
    <AuthProvider
      userManager={userManager}
      onSigninCallback={cleanUrl}
    >
      <App />
    </AuthProvider>
  </StrictMode>,
);

After sign-in, Keycloak sends the browser back with the code and the state in the URL, and cleanUrl, passed as onSigninCallback, removes them once the provider has used them. The library’s README says that an app must provide such a callback. The demo had none. On a reload, its provider tried to use the spent code again, failed, and reported an error the demo never displayed, so a signed-in user appeared signed out.

Calls to the API go through one function:

import { renew, userManager } from "./auth";

const apiUrl = import.meta.env.VITE_API_URL;

function send(path: string, token?: string) {
  const headers: HeadersInit = token
    ? { Authorization: `Bearer ${token}` }
    : {};
  return fetch(`${apiUrl}${path}`, { headers });
}

export async function apiGet(path: string) {
  const user = await userManager.getUser();
  const res = await send(path, user?.access_token);
  if (res.status !== 401 || !user) return res;

  // Expired while the tab slept?
  // Renew once and retry once.
  await res.body?.cancel();
  const renewed = await renew();
  return send(path, renewed?.access_token);
}

The token travels in the Authorization header, never in a URL; RFC 9700 says that “Clients MUST NOT pass access tokens in a URI query parameter.” A 401 gets one renewal and one retry; more would hide a real fault. A 403 goes back to the caller unchanged: the token was valid, and the user lacks the role.

If the renewal fails, so does apiGet. When Keycloak rejects the refresh token with an invalid_grant error, renew() also removes the stored user. The app then shows its Sign in button instead of a user who only looks signed in. A network error leaves the user in place, and the next call tries again. If the server had rotated the token but its answer was lost, that retry sends a used token, which is refused with invalid_grant, and the Sign in button appears. The component decides what to show:

import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { apiGet } from "./api";

export default function App() {
  const auth = useAuth();
  const [result, setResult] = useState("");

  if (auth.isLoading) return <p>Loading...</p>;
  if (!auth.isAuthenticated) {
    return (
      <>
        {auth.error && <p>{auth.error.message}</p>}
        <button onClick={() => auth.signinRedirect()}>
          Sign in
        </button>
      </>
    );
  }

  async function showForecast() {
    try {
      const res = await apiGet("/forecast");
      setResult(res.ok ? await res.text() : `API: ${res.status}`);
    } catch {
      setResult("The request failed. Try again.");
    }
  }

  const profile = auth.user?.profile;
  return (
    <>
      <p>Signed in as {profile?.preferred_username}</p>
      <button onClick={showForecast}>Show forecast</button>
      <button onClick={() => auth.signoutRedirect()}>
        Sign out
      </button>
      <pre>{result}</pre>
    </>
  );
}

The component shows auth.error, where the provider reports a failed sign-in, next to the Sign in button. A page loaded after its access token expired shows that button too: the provider counts an expired user as signed out, and the library schedules no renewal for it. While the Keycloak session lasts, signing in again needs no password, only a round trip to Keycloak.

The app could hide the forecast button from users without the role, and that is good manners, but it is not security. The demo hid its role name behind a SHA-256 hash in the app’s configuration, yet the ID token it received listed the role in plain text. Anything the browser holds, its user can read. The check that counts is the API’s.

How Long a Session Lasts

With Keycloak’s defaults, an access token lasts 5 minutes, and the app renews it after 4 minutes. A session ends after 30 minutes without activity, or after 10 hours in all. Each renewal restarts the idle timer, which the administration guide says resets whenever a client signs in or refreshes a token. With the idle timeout cut to 2 minutes, a refresh every minute kept a test session alive for more than 4 minutes. After 150 seconds without one, the next refresh failed with “Token is not active.”

So, as long as its background renewals succeed, an open tab can keep an idle session alive for up to 10 hours. If idle users must be signed out, stop renewing while the user is idle and let the session lapse.

Signing out goes through Keycloak. The Sign out button calls signoutRedirect, which passes the ID token as id_token_hint, as the RP-Initiated Logout specification recommends. It also passes the registered post_logout_redirect_uri, which brings the user back. In testing, Keycloak ended the session, the app removed the stored user, and the old refresh token was refused as “Session not active.” The old access token, though, still worked at the API, and would until it expired, plus the API’s 30 seconds of clock tolerance.

The API checks tokens on its own, so it never hears about a sign-out, or about a role removed in Keycloak. Introspection, covered in the client credentials article, closes that gap at the cost of a call per request.

Where the Tokens Live

By default, oidc-client-ts keeps the user and the tokens in sessionStorage, which disappears with the tab. Keycloak’s JavaScript adapter keeps them in memory only. RFC 10017 compares the storage options and concludes that “none of these options can fully mitigate token exfiltration when the attacker can execute malicious code in the application’s execution environment.”

A tab that signs in on its own gets tokens of its own. A page that the app opens with window.open, though, starts with a copy of the opener’s sessionStorage, as MDN notes. In testing, with rotation on, the two copies came due together: one renewal was refused, and neither tab could renew again. Open such pages without an opener, as MDN suggests. Signing in on the new page then needs no password, thanks to Keycloak’s session, and the two tabs renewed at the same moment without trouble.

Storage is not even the main problem. A script injected into the app can ask Keycloak for fresh tokens of its own, which even perfectly isolated storage would not prevent. Against that attacker, RFC 10017 warns, “Short access token lifetimes and refresh token rotation are ineffective, since the attacker has a fresh, independent set of tokens.”

So the defenses that matter most for this design keep hostile script out of the page. They include a nonce-based or hash-based Content Security Policy, as the RFC suggests, no HTML built from untrusted strings, and few, well-kept dependencies. Short lifetimes and rotation still limit the damage from a token that leaks some other way, such as through a log.

DPoP, defined in RFC 9449, binds tokens to a key pair that the app holds, which limits the replay of a leaked token. It does not stop an injected script, RFC 10017 notes, because the script can set up a key pair of its own.

Keycloak’s release notes for version 26.4 call DPoP “now fully supported,” and oidc-client-ts supports it too. For a public client, the server then binds both tokens, and the API has to verify the access token’s binding, which the code above does not. A client policy can bind only the refresh token instead, which Keycloak’s DPoP guide says protects public clients while APIs without DPoP support keep working.

The library renews with the refresh token, which Keycloak issues to public clients by default; without one, it falls back to a hidden frame, the older approach. Such a frame needs Keycloak’s session cookie, and browsers have been withholding cookies from frames on other sites.

Safari has blocked them by default since March 2020, and Firefox gives each site a separate cookie jar for all desktop users. Chrome blocks them by default in Incognito mode and otherwise leaves the choice to its users, as Google decided in April 2025. RFC 10017 draws the conclusion: “in light of the impact of third-party cookie-blocking mechanisms, the use of refresh tokens has become significantly more attractive.”

When to Use a Backend for Frontend

RFC 10017 presents its three architectures “in decreasing order of security”:

For the kinds of apps named at the start, the RFC calls the BFF “strongly recommended,” and Microsoft recommends it for web apps without a backend of their own.

Most of this article survives the move. The API stays as it is, checking the audience and the role, and the realm keeps its roles and mappers. The BFF takes over the sign-in redirect, and the app’s client becomes confidential. Running on a server, the BFF can authenticate itself, preferably with a key rather than a shared secret, as the client credentials article explains.

The token code leaves the browser, and a session cookie takes its place. The RFC requires the HttpOnly and Secure flags on the BFF’s cookies and recommends SameSite=Strict. Because the browser now authenticates with a cookie, the BFF also “MUST implement a proper CSRF defense.” One such defense is a custom header that the BFF requires on every call. A request from another origin then needs a CORS preflight, which the BFF can refuse.

The BFF “MUST enforce strict outbound request controls” as well, forwarding only to the APIs it knows. It keeps tokens out of reach of injected script, but such a script can send requests through it, so the defenses above apply there too.

Running It with Aspire

Aspire starts all three parts with one command. Aspire 13, released on 11 November 2025, dropped the .NET prefix from its name. It now does what the demo’s AppHost did in about 250 lines of its own code. Its Keycloak integration runs the server in a container, imports realm files at startup, and serves it over HTTPS with the ASP.NET Core development certificate. The integration remains a preview package, Aspire.Hosting.Keycloak 13.6.1-preview.1.26506.6, and defaults to Keycloak 26.6, so the AppHost pins the current release:

var builder = DistributedApplication
    .CreateBuilder(args);

// The realm's redirect URIs and
// the issuer URL use these ports.
const string keycloakUrl = "https://localhost:3443";
const string spaUrl = "https://localhost:3446";
var authority = $"{keycloakUrl}/realms/demo";

var keycloak = builder
    .AddKeycloak("keycloak", 3443)
    .WithImageTag("26.8.1")
    .WithRealmImport("./realms");

var api = builder
    .AddProject<Projects.Api>("api")
    .WithEnvironment("Authority", authority)
    .WithEnvironment("SpaOrigin", spaUrl)
    .WaitFor(keycloak);

builder.AddViteApp("spa", "../spa")
    .WithHttpsDeveloperCertificate()
    .WithEndpoint("http", e => e.Port = 3446)
    .WithEnvironment("VITE_AUTHORITY", authority)
    .WithEnvironment("VITE_API_URL", api.GetEndpoint("https"))
    .WaitFor(api);

builder.Build().Run();

The AppHost project references the API project, finds the app in ../spa, and imports the realm file from ./realms. The realm’s redirect URIs fix the app’s port, and the issuer URL fixes Keycloak’s. WaitFor starts the API once Keycloak reports healthy, and the app once the API is running. WithHttpsDeveloperCertificate serves the Vite app over HTTPS, on the endpoint that AddViteApp names http. The method is marked experimental, so the AppHost project suppresses the ASPIRECERTIFICATES001 warning.

The development certificate must be trusted (dotnet dev-certs https --trust), because Aspire uses it only when it is present and trusted. Without a data volume, Keycloak starts empty and imports the realm on every run, which suits a demo. With Podman instead of Docker, set ASPIRE_CONTAINER_RUNTIME to podman. Run under this AppHost, Keycloak 26.8.1 imported the realm, and the app and the API behaved as described above.

A Checklist

My Software Development Principles series has entries on least privilege, complete mediation, and zero trust, with the signs that each is applied well or badly. For OpenID Connect in a browser app, the article comes down to this:

Further Reading