> ## Documentation Index
> Fetch the complete documentation index at: https://api.simkl.org/llms.txt
> Use this file to discover all available pages before exploring further.

# PKCE in AUTH V2

> S256 is mandatory on every V2 authorization. How to generate the verifier and challenge, and the mistakes that produce invalid_grant.

**PKCE (RFC 7636) is mandatory for every V2 authorization, for every client type.** There is no opt-out, and `plain` is not accepted — only `S256`. Build it in from the start; it is not something you can add later.

<Note>
  **This applies to the authorization code flow only.** The [device flow](/api-reference/oauth2-device) has no `code_challenge` — the device code itself is the secret, and it is bound to your `client_id` on the server. Nothing on this page applies there.
</Note>

## The two values

| Value            | What it is                                                      | Where it goes                                                 |
| ---------------- | --------------------------------------------------------------- | ------------------------------------------------------------- |
| `code_verifier`  | A random string you generate and **keep**.                      | Sent to `POST /oauth2/token`, never through the browser.      |
| `code_challenge` | The SHA-256 of the verifier, base64url-encoded without padding. | Sent to `GET /oauth2/authorize`, travels through the browser. |

The point is that the challenge is useless on its own. Someone who intercepts the redirect and steals your authorization code still cannot exchange it, because they cannot produce the verifier that hashes to the challenge you registered.

## Generating them

The verifier must be **43 to 128 characters**, using only `A-Z a-z 0-9 - . _ ~`. The usual approach is 32 random bytes, base64url-encoded, which lands at 43 characters.

<CodeGroup>
  ```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
  import base64, hashlib, secrets

  verifier  = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
  challenge = base64.urlsafe_b64encode(
      hashlib.sha256(verifier.encode()).digest()
  ).rstrip(b"=").decode()
  ```

  ```js Node.js theme={"theme":{"light":"github-light","dark":"vesper"}}
  import { randomBytes, createHash } from "node:crypto";

  const b64url = (buf) => buf.toString("base64url");

  const verifier  = b64url(randomBytes(32));
  const challenge = b64url(createHash("sha256").update(verifier).digest());
  ```

  ```swift Swift theme={"theme":{"light":"github-light","dark":"vesper"}}
  import CryptoKit
  import Foundation
  import Security   // SecRandomCopyBytes lives here, not in CryptoKit

  func b64url(_ data: Data) -> String {
      data.base64EncodedString()
          .replacingOccurrences(of: "+", with: "-")
          .replacingOccurrences(of: "/", with: "_")
          .replacingOccurrences(of: "=", with: "")
  }

  var bytes = [UInt8](repeating: 0, count: 32)
  // Check the status. On failure `bytes` is still all zeroes, which would give
  // you a perfectly valid-looking but entirely predictable verifier.
  guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
      fatalError("Could not generate a secure code_verifier")
  }

  let verifier  = b64url(Data(bytes))
  let challenge = b64url(Data(SHA256.hash(data: Data(verifier.utf8))))
  ```

  ```kotlin Kotlin (Android) theme={"theme":{"light":"github-light","dark":"vesper"}}
  import android.util.Base64
  import java.security.MessageDigest
  import java.security.SecureRandom

  // NO_WRAP matters: without it the encoder inserts newlines, which are not in
  // the verifier alphabet and would be rejected as a malformed code_verifier.
  val flags = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP

  val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) }
  val verifier = Base64.encodeToString(bytes, flags)
  val challenge = Base64.encodeToString(
      MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()), flags
  )
  ```

  ```kotlin Kotlin (JVM) theme={"theme":{"light":"github-light","dark":"vesper"}}
  import java.security.MessageDigest
  import java.security.SecureRandom
  import java.util.Base64

  // java.util.Base64 rather than android.util.Base64 — outside Android, or on
  // Android with minSdk 26+, this is the one you want.
  val b64url = Base64.getUrlEncoder().withoutPadding()

  val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) }
  val verifier = b64url.encodeToString(bytes)
  val challenge = b64url.encodeToString(
      MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
  )
  ```
</CodeGroup>

## Using them

Send the challenge on the authorize redirect:

```
https://simkl.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code&scope=media:read%20media:write&state=RANDOM_CSRF_TOKEN&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256
```

Then send the verifier on the exchange. This example is an app or device registration — **Mobile, desktop & browser apps** or **TV, devices & command line** — neither of which has a secret. A **Server apps & services** registration must also authenticate, either with `Authorization: Basic` or a `client_secret` parameter; see [the authorization code guide](/api-reference/oauth2-authorization-code) for the two variants side by side.

```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "User-Agent: my-app-name/1.0" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=YOUR_CLIENT_ID" \
  --data-urlencode "code=AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
  --data-urlencode "code_verifier=YOUR_CODE_VERIFIER"
```

Store the verifier somewhere that survives the browser round-trip — a server-side session, or secure storage on the device. Losing it means the user has to authorize again.

## What goes wrong

<Warning>
  **A failed exchange still consumes the authorization code.** Once the request passes client authentication, the code is invalidated before the PKCE check runs. So if your verifier is wrong, you cannot fix it and retry the same code — it is already gone, and the user has to go through the consent screen again.

  This is correct per RFC 6749 §4.1.2, and it means a PKCE bug costs a full round-trip every time you test it. Get the verifier right in isolation before wiring the flow together — the RFC test vector below lets you do that without touching the API.
</Warning>

<Note>
  **The one exception: `401 invalid_client` does not consume the code.** Client authentication runs before the code is touched, so a request rejected for a missing or wrong `client_secret` never reaches it.

  That matters while you are getting a server app's credentials right: the code survives, and you can retry the same one once the secret is fixed, as long as you are inside the 10-minute window.
</Note>

<Note>
  **A malformed verifier reports `invalid_grant`, not `invalid_request`.** If the verifier is shorter than 43 characters, longer than 128, or contains a character outside `A-Z a-z 0-9 - . _ ~`, the error you get back is:

  ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
  { "error": "invalid_grant", "error_description": "PKCE verification failed" }
  ```

  That is the same error as a genuinely mismatched verifier, so the message will not tell you which of the two happened. If PKCE verification fails, check the length and alphabet of your verifier before assuming your hashing is wrong.
</Note>

The three encoding mistakes that produce a mismatch, in rough order of how often they happen:

1. **Standard base64 instead of base64url.** `+` and `/` must be `-` and `_`.
2. **Leaving the `=` padding on.** Strip it from both values.
3. **Hashing the wrong thing.** The challenge is the SHA-256 of the verifier *string*, not of the random bytes you generated it from.

A quick self-check: if your verifier is `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk`, the challenge must be `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`. That pair is the worked example from RFC 7636 Appendix B, so any correct implementation reproduces it.

<Warning>
  **An unregistered `redirect_uri` masks a missing `code_challenge`.** Redirect URI validation runs first, so while you are still getting the redirect URI wrong you will not find out that your PKCE parameters are also missing. Fix the redirect URI first, then debug PKCE.
</Warning>

## See also

<CardGroup cols={2}>
  <Card title="OAuth flow" icon="lock" href="/api-reference/oauth2-authorization-code">
    The full walkthrough this page plugs into.
  </Card>

  <Card title="AUTH V2 overview" icon="shield-halved" href="/api-reference/auth-v2">
    Client types, token lifetimes, and which flow to use.
  </Card>

  <Card title="Migrating from V1" icon="arrow-right" href="/guides/migrating-v1-to-v2">
    Porting existing code, including what changes about PKCE.
  </Card>

  <Card title="Scopes" icon="key" href="/api-reference/oauth2-scopes">
    What to put in the `scope` parameter on the authorize URL.
  </Card>
</CardGroup>
