Sitemap

Implementing OIDC Authorization Code Flow with PKCE in ASP.NET (OWIN)

6 min readSep 10, 2026

--

If you read the companion piece on SAML and OIDC, you already know the shape of the handshake: redirect to the identity provider, authenticate, come back with a code, exchange that code for tokens. This article goes one level deeper, implemented against classic ASP.NET (.NET Framework, MVC 5) using OWIN/Katana middleware rather than ASP.NET Core. A lot of enterprise codebases are still running on .NET Framework, and the OIDC story there looks noticeably different; PKCE in particular isn’t handled automatically the way it is in the newer stack, so we’re going to wire it up by hand.

Why PKCE, Even for a Confidential Client

PKCE (Proof Key for Code Exchange, pronounced “pixy”) was originally designed to protect public clients, mobile apps, and single-page apps that can’t safely store a client secret. But the guidance has shifted. Even confidential clients like a traditional ASP.NET MVC app, which does hold a secret server-side, benefit from PKCE because it protects against authorization code interception attacks regardless of client type. Most identity providers (Okta, Azure AD, Auth0, Keycloak included) now recommend PKCE universally.

Get Sanjay Bhavikatti’s stories in your inbox

Join Medium for free to get updates from this writer.

The mechanism is simple:

  1. Before redirecting the user, the app generates a random code_verifier.
  2. It hashes that verifier with SHA-256 to produce a code_challenge.
  3. The code_challenge is sent in the initial authorization request.
  4. When exchanging the authorization code for tokens later, the app sends the original code_verifier.
  5. The identity provider hashes the verifier it receives and checks it matches the challenge from step 3.

Unlike ASP.NET Core’s OIDC handler, the Microsoft.Owin.Security.OpenIdConnect middleware used in .NET Framework projects doesn't generate or attach PKCE parameters out of the box. We'll do it ourselves using the middleware's notification hooks.

Project Setup

Install-Package Microsoft.Owin.Security.OpenIdConnect
Install-Package Microsoft.Owin.Security.Cookies
Install-Package Microsoft.Owin.Host.SystemWeb

This assumes a standard ASP.NET MVC 5 project with OWIN startup already wired in via Startup.cs and [assembly: OwinStartup(typeof(OidcPkceDemo.Startup))].

Configuring the OWIN Middleware

Startup.cs:

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;

[assembly: OwinStartup(typeof(OidcPkceDemo.Startup))]

namespace OidcPkceDemo
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

app.UseCookieAuthentication(new CookieAuthenticationOptions());

app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Authority = "https://your-idp-domain/oauth2/default",
ClientId = "your-client-id",
ClientSecret = "your-client-secret",
RedirectUri = "https://localhost:44300/signin-oidc",
ResponseType = "code",
Scope = "openid profile email",
SignInAsAuthenticationType = CookieAuthenticationDefaults.AuthenticationType,

Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = OnRedirectToIdentityProvider,
AuthorizationCodeReceived = OnAuthorizationCodeReceived
}
});
}

// Attach PKCE parameters before redirecting to the authorize endpoint
private Task OnRedirectToIdentityProvider(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> n)
{
if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication)
{
var verifier = GenerateCodeVerifier();
var challenge = GenerateCodeChallenge(verifier);

// Stash the verifier in a short-lived cookie so we can retrieve it
// during the token exchange step later in this request lifecycle.
n.OwinContext.Response.Cookies.Append("pkce_verifier", verifier, new Microsoft.Owin.CookieOptions
{
HttpOnly = true,
Secure = true,
Expires = DateTime.UtcNow.AddMinutes(10)
});

n.ProtocolMessage.SetParameter("code_challenge", challenge);
n.ProtocolMessage.SetParameter("code_challenge_method", "S256");
}

return Task.CompletedTask;
}

// Attach the verifier when exchanging the code for tokens
private Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedNotification n)
{
var verifier = n.OwinContext.Request.Cookies["pkce_verifier"];
if (!string.IsNullOrEmpty(verifier))
{
n.TokenEndpointRequest.SetParameter("code_verifier", verifier);
}

return Task.CompletedTask;
}

private static string GenerateCodeVerifier()
{
var bytes = new byte[32];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
return Base64UrlEncode(bytes);
}

private static string GenerateCodeChallenge(string verifier)
{
using (var sha256 = SHA256.Create())
{
var hash = sha256.ComputeHash(System.Text.Encoding.ASCII.GetBytes(verifier));
return Base64UrlEncode(hash);
}
}

private static string Base64UrlEncode(byte[] input)
{
return Convert.ToBase64String(input)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
}
}
}

What Happens When You Hit [Authorize]

Decorate any controller action with [Authorize]:

using System.Web.Mvc;

[Authorize]
public class DashboardController : Controller
{
public ActionResult Index()
{
var name = User.Identity.Name;
return View(model: name);
}
}

When an unauthenticated user hits this route, here’s the sequence:

  1. OnRedirectToIdentityProvider fires, generating the code_verifier, deriving the code_challenge, stashing the verifier in a short-lived HTTP-only cookie, and attaching the challenge to the outgoing request.
  2. The OWIN middleware also generates a state parameter (CSRF protection) and a nonce (replay protection for the ID token) automatically.
  3. The browser is redirected to the authorize endpoint:
GET /oauth2/default/v1/authorize?
response_type=code
&client_id=your-client-id
&redirect_uri=https://localhost:44300/signin-oidc
&scope=openid profile email
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
  1. The user authenticates at the identity provider.
  2. The provider redirects back to redirect_uri (/signin-oidc) with an authorization code and the same state value.
  3. The middleware verifies the returned state, rejecting the response if it doesn't match. This is what stops CSRF-style injection of a forged authorization response.
  4. OnAuthorizationCodeReceived fires, reading the pkce_verifier cookie and attaching it to the token exchange:
POST /oauth2/default/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=P7q2Eu...
&redirect_uri=https://localhost:44300/signin-oidc
&client_id=your-client-id
&client_secret=your-client-secret
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
  1. The response contains an access_token, an id_token (a JWT), and optionally a refresh_token.
  2. The middleware validates the ID token: signature against the provider’s JWKS, iss matches the configured authority, aud matches the client ID, exp hasn't passed, and nonce matches what was generated earlier.
  3. Only after all of that passes does it construct the ClaimsPrincipal, sign in via the cookie middleware, and let the request continue to DashboardController.

Steps 6 and 9 are the two checks worth internalizing, since they’re the ones that get skipped when developers hand-roll OIDC instead of trusting the maintained middleware. State validation stops forged callbacks. Nonce validation stops a captured ID token from being replayed in a different session.

Manually Inspecting the ID Token

Add this to any authenticated action to see the raw claims:

using System.Linq;
using System.Web.Mvc;

[Authorize]
public class DashboardController : Controller
{
public JsonResult Claims()
{
var claims = ((System.Security.Claims.ClaimsIdentity)User.Identity)
.Claims
.Select(c => new { c.Type, c.Value });

return Json(claims, JsonRequestBehavior.AllowGet);
}
}

A decoded ID token payload typically looks like:

{
"iss": "https://your-idp-domain/oauth2/default",
"sub": "00u1a2b3c4d5e6f7g8h9",
"aud": "your-client-id",
"exp": 1893456000,
"iat": 1893452400,
"nonce": "n-0S6_WzA2Mj",
"name": "Jane Doe",
"email": "jane.doe@example.com"
}

sub is the one claim you should treat as the durable, unique user identifier, never email, since email addresses can change or be reassigned.

Refresh Tokens and Silent Renewal

Access tokens are intentionally short-lived, often 5 to 60 minutes. Rather than forcing the user to log in again on every expiry, request the offline_access scope (if the provider supports it) to receive a refresh token, and handle renewal manually since the older OWIN middleware doesn't do this for you:

using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;

public async Task<string> RefreshAccessTokenAsync(string refreshToken)
{
using (var client = new HttpClient())
{
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = "your-client-id",
["client_secret"] = "your-client-secret"
});

var response = await client.PostAsync("https://your-idp-domain/oauth2/default/v1/token", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}

Store refresh tokens server-side (encrypted, or in a secure token store, e.g. AuthenticationProperties persisted through the ticket, or a dedicated database table), never in client-accessible storage. A leaked refresh token is a long-lived credential, arguably more dangerous than a leaked access token precisely because it survives past the access token's short window.

Common Pitfalls in .NET Framework Implementations

  • Assuming PKCE is handled automatically. Unlike ASP.NET Core, the OWIN OpenIdConnectAuthenticationOptions has no UsePkce flag. Skipping the notification hooks above means you silently ship an OIDC integration without PKCE protection.
  • Forgetting RequireHttpsMetadata equivalents in production. Running the identity endpoints over plain HTTP in any non-local environment defeats the purpose of the token exchange.
  • Not clearing or scoping Scope correctly. The Scope property is a single space-delimited string here, not a collection, so a typo silently changes what claims come back.
  • Relying on email as a unique key. Use sub, paired with iss, as the actual identity key in your database.
  • Storing the PKCE verifier insecurely. The cookie holding the code_verifier must be HttpOnly and Secure, and short-lived, since it's only needed for the few seconds between redirect and callback.
  • Skipping token expiry handling. Without the manual refresh logic above, users get silently logged out mid-session with a confusing redirect loop back through the login flow.

Wrapping Up

The OWIN OpenID Connect middleware gets you most of the way there: state and nonce validation, ID token signature checks, and the redirect/callback plumbing, but PKCE on .NET Framework is something you have to wire up yourself using the notification pipeline. Once it’s in place, the protocol is doing exactly what SAML and OIDC were built to do: proving identity once, at one trusted provider, and letting every downstream service simply check the proof, even on a codebase that predates ASP.NET Core.

--

--