Implementing OIDC Authorization Code Flow with PKCE in ASP.NET (OWIN)
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:
- Before redirecting the user, the app generates a random
code_verifier. - It hashes that verifier with SHA-256 to produce a
code_challenge. - The
code_challengeis sent in the initial authorization request. - When exchanging the authorization code for tokens later, the app sends the original
code_verifier. - 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.SystemWebThis 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:
OnRedirectToIdentityProviderfires, generating thecode_verifier, deriving thecode_challenge, stashing the verifier in a short-lived HTTP-only cookie, and attaching the challenge to the outgoing request.- The OWIN middleware also generates a
stateparameter (CSRF protection) and anonce(replay protection for the ID token) automatically. - 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- The user authenticates at the identity provider.
- The provider redirects back to
redirect_uri(/signin-oidc) with an authorization code and the samestatevalue. - 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. OnAuthorizationCodeReceivedfires, reading thepkce_verifiercookie 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- The response contains an
access_token, anid_token(a JWT), and optionally arefresh_token. - The middleware validates the ID token: signature against the provider’s JWKS,
issmatches the configured authority,audmatches the client ID,exphasn't passed, andnoncematches what was generated earlier. - Only after all of that passes does it construct the
ClaimsPrincipal, sign in via the cookie middleware, and let the request continue toDashboardController.
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
OpenIdConnectAuthenticationOptionshas noUsePkceflag. Skipping the notification hooks above means you silently ship an OIDC integration without PKCE protection. - Forgetting
RequireHttpsMetadataequivalents 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
Scopecorrectly. TheScopeproperty is a single space-delimited string here, not a collection, so a typo silently changes what claims come back. - Relying on
emailas a unique key. Usesub, paired withiss, as the actual identity key in your database. - Storing the PKCE verifier insecurely. The cookie holding the
code_verifiermust beHttpOnlyandSecure, 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.




