PKCE: how I did it

I wanted to understand how PKCE actually works, so I implemented it from scratch in C#. Best way to learn a spec is to write the code yourself — you can't skim over the parts that don't make sense yet.

The short version: instead of sending a secret with the token request, you generate a random string (the verifier), hash it (the challenge), send the hash upfront when you start the login, then prove you have the original at the end when you exchange the code. Nothing sensitive ever travels over the wire. No client secret needed.

The whole thing lives behind an interface:

public interface IPkceService
{
    PkceParameters GeneratePkceParameters();
    string? GetCodeVerifier(string state);
    void ClearCodeVerifier(string state);
    Task<TokenResult> RequestTokenAsync(string code, string state);
}

GeneratePkceParameters is called when the login starts. RequestTokenAsync is called when the callback comes back. The two in the middle are just cache helpers — the verifier needs to survive between those two HTTP requests, and IMemoryCache is the simplest way to do that without anything external.

Generating the parameters

At login start, I generate a state (for CSRF protection) and a codeVerifier (the PKCE secret), hash the verifier into the codeChallenge, then cache the verifier keyed by state so I can retrieve it during the callback:

public PkceParameters GeneratePkceParameters()
{
    var state = GenerateRandomString(StateLength);
    var codeVerifier = GenerateRandomString(CodeVerifierLength);
    var codeChallenge = GenerateCodeChallenge(codeVerifier);

    _cache.Set($"code_verifier_{state}", codeVerifier, TimeSpan.FromMinutes(CacheMiss));

    return new PkceParameters
    {
        State = state,
        CodeChallenge = codeChallenge,
        CodeChallengeMethod = "S256"
    };
}

The returned PkceParameters go straight into the authorization URL as query params. CacheMiss is 10 (minutes). Bad name — it's actually the expiry. Works though, 10 minutes is more than enough time for a user to go through the IdP login page.

The random string

Both state and codeVerifier come from the same helper. I was going to use two separate generators but they're the same operation with different lengths, so one method makes more sense:

private string GenerateRandomString(int length)
{
    var randomBytes = new byte[length];
    RandomNumberGenerator.Fill(randomBytes);

    var result = new StringBuilder(length);
    foreach (var b in randomBytes)
    {
        result.Append(AllowedChars[b % AllowedChars.Length]);
    }

    return result.ToString();
}

RandomNumberGenerator.Fill matters here — this is security-sensitive code and System.Random isn't cryptographically secure. Each byte gets mapped to one of the unreserved characters from the PKCE spec (A-Z, a-z, 0-9, -._~), so the output is always URL-safe without encoding.

The challenge

The challenge is SHA-256 of the verifier, Base64url-encoded. The spec calls this method S256 — which is why CodeChallengeMethod = "S256" above:

private string GenerateCodeChallenge(string codeVerifier)
{
    var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
    return Base64UrlEncode(hash);
}

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

Standard Base64 uses +, /, and = which are problematic in URLs. Base64url swaps them out. Convert.ToBase64String gets you most of the way there, three replacements finishes the job.

Exchanging the code

When the callback arrives I look up the cached verifier by state, include it in the token request, and clear it from the cache whether the request succeeds or fails (it's one-use):

public async Task<TokenResult> RequestTokenAsync(string code, string state)
{
    var codeVerifier = GetCodeVerifier(state);
    if (codeVerifier is null)
        return new TokenResult { Success = false, Error = "Invalid or expired state parameter", StatusCode = 400 };

    var tokenRequestBody = new Dictionary<string, string>
    {
        ["grant_type"] = "authorization_code",
        ["client_id"] = clientId!,
        ["code"] = code,
        ["code_verifier"] = codeVerifier,
        ["redirect_uri"] = redirectUri!
    };

    var response = await httpClient.PostAsync(
        $"https://{authority}/oauth/token",
        new FormUrlEncodedContent(tokenRequestBody)
    );

    // handle error...

    ClearCodeVerifier(state);
    var tokenResponse = await response.Content.ReadAsStringAsync();
    return new TokenResult { Success = true, TokenResponse = tokenResponse, StatusCode = 200 };
}

The authorization server hashes the verifier it receives and compares it against the challenge from the first request. If they match, you get a token. If the state isn't in the cache — either expired or it never existed — that's a 400 back to the caller. You can't replay the flow with a stale state.