> ## 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.

# OAuth 2.0 + PKCE for public clients

> OAuth 2.0 without `client_secret` for mobile apps, single-page apps, browser extensions, desktop binaries, and any other public client. Uses `code_verifier` + `code_challenge` to prove the redeeming client is the same one that initiated the flow.

PKCE — *Proof Key for Code Exchange*, [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) — is the secure way to run OAuth on a **public client**: any app where you can't safely embed a `client_secret`. The browser-based UX is identical to standard OAuth; the only difference is that the `client_secret` is replaced by a one-time `code_verifier` + `code_challenge` pair the client generates locally.

Even if an attacker intercepts the redirect with the authorization `code`, they can't redeem it without the verifier — and the verifier never leaves your app until the final POST.

## Pick this flow if…

| Use PKCE                                          | Use a different flow                                                            |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| iOS, iPadOS, watchOS apps                         | Server-side web apps that can keep a secret → [OAuth 2.0](/api-reference/oauth) |
| Android, Wear OS apps                             | TVs, consoles, smart watches, CLI tools → [PIN flow](/api-reference/pin)        |
| Single-page apps (React, Vue, Svelte, vanilla JS) |                                                                                 |
| Browser extensions (Chrome, Firefox, Edge)        |                                                                                 |
| Desktop binaries (Electron, Tauri, native)        |                                                                                 |

If you'd otherwise be embedding `client_secret` in a public binary, you want PKCE.

<Warning>
  **Two domains, two roles.** OAuth uses **two different hosts** — easy to mix up, and the most common cause of "404 Not Found" during integration:

  | Endpoint               | Host                | What it does                                                                                        |
  | ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------- |
  | `GET /oauth/authorize` | **`simkl.com`**     | Browser-facing consent page. The user lands here, signs in, and approves your app.                  |
  | `POST /oauth/token`    | **`api.simkl.com`** | Server-to-server code exchange. Your backend posts the `code` here and gets back an `access_token`. |

  If your authorize URL points at `api.simkl.com` you'll get a 404 — it has to be `simkl.com`.
</Warning>

## At a glance

Three steps, two HTTP calls:

|                             | Where                       | What you send                                                        | What you get                                                             |
| --------------------------- | --------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **1. Generate locally**     | client                      | —                                                                    | A random `code_verifier` (43–128 chars) and its SHA-256 `code_challenge` |
| **2. Authorize (browser)**  | `simkl.com/oauth/authorize` | `client_id`, `code_challenge`, `code_challenge_method=S256`, `state` | After consent: a `?code=…&state=…` redirect                              |
| **3. Exchange (HTTP POST)** | `api.simkl.com/oauth/token` | `code`, `client_id`, `code_verifier`                                 | A long-lived `access_token`                                              |

Tokens last until the user revokes from [Connected Apps](https://simkl.com/settings/connected-apps/) — there's no refresh-token flow.

## Detailed flow

<Steps>
  <Step title="Generate a verifier and challenge" icon="dice">
    Locally, before any redirect:

    ```js theme={"theme":{"light":"github-light","dark":"vesper"}}
    // Pseudocode — use your platform's crypto APIs (see recipes below).
    code_verifier  = base64url(random(32 bytes))      // 43–128 unreserved chars
    code_challenge = base64url(sha256(code_verifier)) // S256 (recommended)
    ```

    **`code_verifier` requirements** ([RFC 7636 §4.1](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)):

    * 43–128 characters
    * Only the *unreserved URI characters*: `A-Z`, `a-z`, `0-9`, and `- . _ ~`
    * Cryptographically random — never reuse across flows

    Simkl enforces all three at the token-exchange step — a verifier outside the length range or carrying any other character returns `401 secret_error` ("PKCE verification failed"). The error message intentionally matches what a wrong-but-well-formed verifier returns, so format probing reveals nothing.

    **`code_challenge_method`** — Simkl supports two values:

    | Method                          | How it works                                                                                                |
    | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
    | `S256` *(default, recommended)* | `code_challenge = base64url(sha256(code_verifier))` — protects the verifier even if the authorize URL leaks |
    | `plain`                         | `code_challenge = code_verifier` — only acceptable on platforms without SHA-256 (essentially never in 2026) |

    <Warning>
      **`code_challenge_method` is case-sensitive.** Send exactly `S256` or `plain` — not `s256`, `sha256`, or any other variant. Anything outside the two-value enum is rejected immediately at the authorize step:

      ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
      { "error": "invalid_request",
        "error_description": "code_challenge_method must be S256 or plain (RFC 7636 §4.3)" }
      ```

      If you see that error, double-check the casing.
    </Warning>

    **Where to keep the verifier between step 1 and step 3:**

    | Platform               | Storage                                                                                      |
    | ---------------------- | -------------------------------------------------------------------------------------------- |
    | iOS / Android          | In-process memory (or Keychain / EncryptedSharedPreferences if the flow may be backgrounded) |
    | SPA                    | `sessionStorage` — survives the redirect, cleared on tab close                               |
    | Desktop CLI / loopback | Process memory while the local HTTP server waits                                             |
    | Browser extension      | `chrome.storage.session` (or the equivalent)                                                 |
  </Step>

  <Step title="Authorize" icon="arrow-up-right-from-square">
    Open a browser session to:

    ```
    https://simkl.com/oauth/authorize
      ?response_type=code
      &client_id=YOUR_CLIENT_ID
      &redirect_uri=YOUR_APP_DEEP_LINK    (optional with PKCE — see "Choose a redirect URI" below)
      &code_challenge=YOUR_CODE_CHALLENGE
      &code_challenge_method=S256
      &state=YOUR_RANDOM_CSRF_TOKEN
      &app-name=my-app-name
      &app-version=1.0
    ```

    Use a platform-secure browser session — **not** an embedded WebView:

    * **iOS / iPadOS** — `ASWebAuthenticationSession`
    * **Android** — Chrome Custom Tabs (or the newer Auth Tab on supported Chrome versions)
    * **Desktop** — the user's system browser, with a localhost loopback redirect
    * **SPA** — a normal full-page navigation
    * **Browser extension** — `chrome.identity.launchWebAuthFlow` / `browser.identity.launchWebAuthFlow`

    Simkl shows a consent screen. After approval, the user is redirected to your `redirect_uri` with `?code=AUTHORIZATION_CODE&state=YOUR_RANDOM_CSRF_TOKEN` appended.

    <Tip>
      **Always send `state` for CSRF protection.** Generate a random per-flow value (e.g. `crypto.randomUUID()`), store it alongside the verifier, and verify on the redirect that the echoed value matches. Especially important on public clients — your custom URI scheme could in principle be triggered by another app on the same device, and `state` is what proves the response is for the flow you initiated.
    </Tip>

    <Warning>
      **User denial doesn't return an `error=` parameter.** If the user clicks "No" on the consent screen, Simkl redirects to `/` on simkl.com — **not** back to your `redirect_uri` with `error=access_denied`. Treat any flow where the redirect never lands within a sensible timeout (e.g. 5 minutes) as a denial / cancellation and let the user retry.
    </Warning>
  </Step>

  <Step title="Exchange the code for a token" icon="key">
    Once you receive the authorization `code`, POST to the token endpoint **with the verifier instead of `client_secret`**:

    ```json POST https://api.simkl.com/oauth/token theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "code":          "AUTHORIZATION_CODE",
      "client_id":     "YOUR_CLIENT_ID",
      "code_verifier": "YOUR_CODE_VERIFIER",
      "redirect_uri":  "YOUR_APP_DEEP_LINK",
      "grant_type":    "authorization_code"
    }
    ```

    <Note>
      **Both content-types and both credential locations work.** Simkl's `POST /oauth/token` accepts:

      * `Content-Type: application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) **or** `Content-Type: application/json` — pick whichever your HTTP client prefers
      * Client credentials in the request body (`client_id` + `client_secret` parameters) **or** in the `Authorization: Basic` header (RFC 6749 §2.3.1) — both paths are honored

      That means **off-the-shelf OAuth libraries work out-of-the-box** with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:

      <CodeGroup>
        ```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
        curl -X POST https://api.simkl.com/oauth/token \
          -H "Content-Type: application/x-www-form-urlencoded" \
          -H "User-Agent: my-app-name/1.0" \
          --data-urlencode "client_id=YOUR_CLIENT_ID" \
          --data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
          --data-urlencode "code=AUTHORIZATION_CODE" \
          --data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
          --data-urlencode "grant_type=authorization_code"
        ```

        ```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
        curl -X POST https://api.simkl.com/oauth/token \
          -H "Content-Type: application/json" \
          -H "User-Agent: my-app-name/1.0" \
          -d '{
            "client_id":     "YOUR_CLIENT_ID",
            "client_secret": "YOUR_CLIENT_SECRET",
            "code":          "AUTHORIZATION_CODE",
            "redirect_uri":  "YOUR_REDIRECT_URI",
            "grant_type":    "authorization_code"
          }'
        ```
      </CodeGroup>

      For library-specific examples (Python, Node, Java, Go, PHP), see [OAuth client libraries](/api-reference/oauth-libraries) — most are zero-config.
    </Note>

    Notice: **no `client_secret`**. Simkl re-derives the challenge from the verifier you send and matches it against what you sent in step 2. If they match, you get back an `access_token`. If they don't, the request fails — that's the protection: an attacker who intercepted the redirect doesn't have the verifier.

    **Successful response (200):**

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "access_token": "...",
      "token_type":   "bearer",
      "scope":        "public",
      "expires_in":   157680000
    }
    ```

    `expires_in` is **5 years in seconds** — Simkl tokens are long-lived and there's no refresh-token grant. If a 401 ever arrives before that lifetime elapses, the user revoked your app at [Connected Apps settings](https://simkl.com/settings/connected-apps/); start a fresh PKCE flow.

    **PKCE failure (401):**

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "error":   "secret_error",
      "code":    401,
      "message": "PKCE verification failed"
    }
    ```

    `secret_error` is also returned for a wrong `client_secret` on the confidential flow — distinguishable by the `message` field. **Don't retry the same `code` after this error** — any exchange attempt (success OR failure) consumes the code (RFC 6749 §4.1.2). Restart the flow from `/oauth/authorize` with a fresh verifier+challenge pair.

    Treat the `access_token` as **long-lived** — store it securely and reuse it on every authenticated request:

    ```http theme={"theme":{"light":"github-light","dark":"vesper"}}
    Authorization: Bearer YOUR_ACCESS_TOKEN
    ```

    Where to store it:

    | Platform                | Storage                                                          |
    | ----------------------- | ---------------------------------------------------------------- |
    | iOS / iPadOS            | Keychain                                                         |
    | Android                 | EncryptedSharedPreferences or the Android Keystore               |
    | macOS / Windows / Linux | OS keychain (Keychain Access, Credential Manager, libsecret)     |
    | SPA                     | `httpOnly` cookie set by your backend — **never** `localStorage` |
    | Browser extension       | `chrome.storage.local` (or the equivalent)                       |
  </Step>
</Steps>

## Choose a redirect URI

PKCE works with any redirect URI scheme — but each option has trade-offs:

| Type                          | Example                           | Best for                    | Notes                                                                                                                                                         |
| ----------------------------- | --------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Custom URI scheme**         | `myapp://oauth`                   | Mobile (iOS, Android)       | Simplest. Risk: another app could in principle register the same scheme — PKCE mitigates this since the attacker can't redeem the code without your verifier. |
| **Universal Link / App Link** | `https://yourdomain.com/oauth`    | Mobile (iOS 9+, Android 6+) | Cryptographically bound to your domain via `apple-app-site-association` / `assetlinks.json`. Strongest mobile option.                                         |
| **HTTPS callback**            | `https://yourdomain.com/callback` | SPA                         | Standard web. The page reads `?code=` from the URL and POSTs to the token endpoint.                                                                           |
| **Loopback**                  | `http://127.0.0.1:8765/callback`  | Desktop / CLI               | Spin up a local HTTP server on a free port; Simkl redirects there. Use `127.0.0.1`, not `localhost`.                                                          |

Whichever you pick, the URI you send in step 2 must **match a URL registered in your [app settings](https://simkl.com/settings/developer/) byte-for-byte** (scheme, host, port, path, trailing slash, casing).

If you can't host a redirect target at all — TVs, consoles, CLI tools — use the [PIN flow](/api-reference/pin) instead. That's the browser-less alternative; the user enters a 5-character code at `simkl.com/pin` and your app polls for the result.

## Platform recipes

<Tabs>
  <Tab title="iOS / Swift">
    Full PKCE flow with `ASWebAuthenticationSession` and `CryptoKit`:

    ```swift theme={"theme":{"light":"github-light","dark":"vesper"}}
    import AuthenticationServices
    import CryptoKit

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

    // Step 1 — verifier + challenge
    let verifier  = base64url(Data((0..<32).map { _ in UInt8.random(in: 0...255) }))
    let challenge = base64url(Data(SHA256.hash(data: Data(verifier.utf8))))
    let state     = UUID().uuidString

    // Step 2 — open ASWebAuthenticationSession
    var components = URLComponents(string: "https://simkl.com/oauth/authorize")!
    components.queryItems = [
      .init(name: "response_type",         value: "code"),
      .init(name: "client_id",             value: "YOUR_CLIENT_ID"),
      .init(name: "redirect_uri",          value: "myapp://oauth"),
      .init(name: "code_challenge",        value: challenge),
      .init(name: "code_challenge_method", value: "S256"),
      .init(name: "state",                 value: state),
      .init(name: "app-name",              value: "my-app-name"),
      .init(name: "app-version",           value: "1.0"),
    ]
    let session = ASWebAuthenticationSession(
      url: components.url!,
      callbackURLScheme: "myapp"
    ) { callbackURL, _ in
      guard let callbackURL,
            let qs = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
            qs.queryItems?.first(where: { $0.name == "state" })?.value == state,
            let code = qs.queryItems?.first(where: { $0.name == "code" })?.value
      else { return }

      // Step 3 — exchange the code for a token
      var req = URLRequest(url: URL(string: "https://api.simkl.com/oauth/token")!)
      req.httpMethod = "POST"
      req.setValue("application/json", forHTTPHeaderField: "Content-Type")
      req.setValue("my-app-name/1.0",  forHTTPHeaderField: "User-Agent")
      req.httpBody = try? JSONSerialization.data(withJSONObject: [
        "code":          code,
        "client_id":     "YOUR_CLIENT_ID",
        "code_verifier": verifier,
        "redirect_uri":  "myapp://oauth",
        "grant_type":    "authorization_code",
      ])
      URLSession.shared.dataTask(with: req) { data, _, _ in
        guard let data,
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let token = json["access_token"] as? String
        else { return }
        // Save `token` to Keychain.
      }.resume()
    }
    session.presentationContextProvider = self
    session.start()
    ```
  </Tab>

  <Tab title="Android / Kotlin">
    Full PKCE flow with Custom Tabs and OkHttp:

    ```kotlin theme={"theme":{"light":"github-light","dark":"vesper"}}
    import android.util.Base64
    import androidx.browser.customtabs.CustomTabsIntent
    import okhttp3.*
    import org.json.JSONObject
    import java.security.MessageDigest

    fun base64url(bytes: ByteArray): String =
      Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)

    // Step 1 — verifier + challenge
    val verifier  = base64url((1..32).map { (0..255).random().toByte() }.toByteArray())
    val challenge = base64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
    val state     = java.util.UUID.randomUUID().toString()
    // Persist verifier + state somewhere your redirect handler can read them.

    // Step 2 — launch a Custom Tab
    val authUri = Uri.parse("https://simkl.com/oauth/authorize").buildUpon()
      .appendQueryParameter("response_type",         "code")
      .appendQueryParameter("client_id",             "YOUR_CLIENT_ID")
      .appendQueryParameter("redirect_uri",          "myapp://oauth")
      .appendQueryParameter("code_challenge",         challenge)
      .appendQueryParameter("code_challenge_method", "S256")
      .appendQueryParameter("state",                 state)
      .appendQueryParameter("app-name",              "my-app-name")
      .appendQueryParameter("app-version",           "1.0")
      .build()
    CustomTabsIntent.Builder().build().launchUrl(context, authUri)

    // Step 3 — in the activity that handles myapp://oauth?code=...&state=...
    fun handleRedirect(intent: Intent) {
      val data = intent.data ?: return
      if (data.getQueryParameter("state") != savedState) return  // CSRF check
      val code = data.getQueryParameter("code") ?: return

      val body = JSONObject(mapOf(
        "code"          to code,
        "client_id"     to "YOUR_CLIENT_ID",
        "code_verifier" to savedVerifier,
        "redirect_uri"  to "myapp://oauth",
        "grant_type"    to "authorization_code",
      )).toString()
      val req = Request.Builder()
        .url("https://api.simkl.com/oauth/token")
        .header("User-Agent", "my-app-name/1.0")
        .post(body.toRequestBody("application/json".toMediaType()))
        .build()
      OkHttpClient().newCall(req).enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
          val token = JSONObject(response.body!!.string()).getString("access_token")
          // Save token to EncryptedSharedPreferences.
        }
        override fun onFailure(call: Call, e: IOException) { /* handle */ }
      })
    }
    ```
  </Tab>

  <Tab title="Web (SPA) / JavaScript">
    Browser-native Web Crypto API — no dependencies:

    ```js theme={"theme":{"light":"github-light","dark":"vesper"}}
    // Step 1 — verifier + challenge (run once before redirecting)
    const base64url = bytes =>
      btoa(String.fromCharCode(...bytes))
        .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

    const verifier  = base64url(crypto.getRandomValues(new Uint8Array(32)));
    const challenge = base64url(new Uint8Array(
      await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
    ));
    const state = crypto.randomUUID();

    // Persist verifier + state across the redirect.
    sessionStorage.setItem("pkce_verifier", verifier);
    sessionStorage.setItem("pkce_state",    state);

    // Step 2 — full-page redirect to the consent screen
    const params = new URLSearchParams({
      response_type:         "code",
      client_id:             "YOUR_CLIENT_ID",
      redirect_uri:          location.origin + "/callback",
      code_challenge:        challenge,
      code_challenge_method: "S256",
      state,
      "app-name":            "my-app-name",
      "app-version":         "1.0",
    });
    location.href = `https://simkl.com/oauth/authorize?${params}`;

    // Step 3 — on /callback after the redirect lands
    const qs = new URLSearchParams(location.search);
    if (qs.get("state") !== sessionStorage.getItem("pkce_state")) {
      throw new Error("State mismatch — possible CSRF attempt");
    }
    const r = await fetch("https://api.simkl.com/oauth/token", {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({
        code:          qs.get("code"),
        client_id:     "YOUR_CLIENT_ID",
        code_verifier: sessionStorage.getItem("pkce_verifier"),
        redirect_uri:  location.origin + "/callback",
        grant_type:    "authorization_code",
      }),
    });
    const { access_token } = await r.json();
    sessionStorage.removeItem("pkce_verifier");
    sessionStorage.removeItem("pkce_state");
    // Send `access_token` to your backend to set as an httpOnly cookie.
    ```
  </Tab>

  <Tab title="Desktop / Python">
    Loopback redirect with `requests`:

    ```python theme={"theme":{"light":"github-light","dark":"vesper"}}
    import base64, hashlib, secrets, urllib.parse, webbrowser, http.server, threading
    import requests

    # Step 1 — verifier + challenge
    verifier  = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode()).digest()
    ).rstrip(b"=").decode()
    state = secrets.token_urlsafe(16)

    # Step 2 — open the user's browser to the consent page
    auth_url = "https://simkl.com/oauth/authorize?" + urllib.parse.urlencode({
        "response_type":         "code",
        "client_id":             "YOUR_CLIENT_ID",
        "redirect_uri":          "http://127.0.0.1:8765/callback",
        "code_challenge":        challenge,
        "code_challenge_method": "S256",
        "state":                 state,
        "app-name":              "my-app-name",
        "app-version":           "1.0",
    })
    webbrowser.open(auth_url)

    # Spin up a one-shot loopback server to capture the redirect.
    code_holder: dict[str, str] = {}
    class Handler(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
            if qs.get("state", [None])[0] != state:
                self.send_response(400); self.end_headers(); return
            code_holder["code"] = qs["code"][0]
            self.send_response(200); self.end_headers()
            self.wfile.write(b"Done — you can close this tab.")
        def log_message(self, *a): pass

    server = http.server.HTTPServer(("127.0.0.1", 8765), Handler)
    threading.Thread(target=server.handle_request, daemon=True).start()
    # Block until the redirect has been handled (or the user cancels).
    while "code" not in code_holder:
        pass

    # Step 3 — exchange
    r = requests.post(
        "https://api.simkl.com/oauth/token",
        headers={"User-Agent": "my-app-name/1.0"},
        json={
            "code":          code_holder["code"],
            "client_id":     "YOUR_CLIENT_ID",
            "code_verifier": verifier,
            "redirect_uri":  "http://127.0.0.1:8765/callback",
            "grant_type":    "authorization_code",
        },
    )
    access_token = r.json()["access_token"]
    ```
  </Tab>
</Tabs>

## Common pitfalls

<AccordionGroup>
  <Accordion title="The token exchange returns an error">
    Most common causes:

    * **`code_verifier` doesn't match the original `code_challenge`** — you probably regenerated the verifier between step 2 and step 3, or stored/loaded it incorrectly (URL-encoded once but not the other, padded vs unpadded base64). Returns `401 secret_error` with `"PKCE verification failed"` in the message field.
    * **Authorization code expired or already used** — codes are short-lived AND single-use (RFC 6749 §4.1.2). Any exchange attempt — success OR failure — consumes the code. If a previous attempt failed for any reason (wrong verifier, format error, network glitch after the request reached the server), the code is gone. Restart from `/oauth/authorize` with a fresh verifier+challenge.
    * **`redirect_uri` not registered** — the URI you send in step 3 must be a registered URL for your app (same whitelist as step 2). Simkl doesn't currently enforce that step 2 and step 3 use the exact same string, but stricter OAuth implementations do — send the same value both times for forward-compat.
    * **`code_challenge_method` was wrong-cased on authorize** — the authorize endpoint rejects anything outside `{S256, plain}` (case-sensitive) with `400 invalid_request`. Re-run the flow with `S256`.
  </Accordion>

  <Accordion title="`base64url` vs standard `base64` mismatch">
    PKCE requires **base64url with no padding** (RFC 4648 §5):

    * Use `-` instead of `+`
    * Use `_` instead of `/`
    * Strip trailing `=` characters

    Many crypto libraries default to standard base64 (with `+`, `/`, and `=`) and you have to opt into the URL-safe variant. If your verifier or challenge contains any of those three characters, the server-side comparison will silently fail and you'll get `PKCE verification failed`. The `base64url` helper at the top of every recipe above does the substitution explicitly.
  </Accordion>

  <Accordion title="The user lands on simkl.com instead of my app">
    Your `redirect_uri` parameter doesn't match a URL registered for your app in [developer settings](https://simkl.com/settings/developer/), or the redirect didn't make it back to your app (custom-scheme handler not registered, Universal Link not signed, etc.). Register the URI byte-for-byte, then pass the same string in step 2. If your app genuinely has no place to redirect (TV, console, CLI), use the [PIN flow](/api-reference/pin) instead.
  </Accordion>

  <Accordion title="iOS in-app browser doesn't return the redirect">
    Use `ASWebAuthenticationSession` (not `SFSafariViewController` or a `WKWebView`) — only `ASWebAuthenticationSession` lets your app receive the deep-link callback when the user returns. Set `callbackURLScheme` to your registered scheme.
  </Accordion>

  <Accordion title="Multiple verifier/challenge pairs in flight">
    Each authorization flow should use its own pair. If the user has two browser tabs trying to authorize in parallel, store the verifier under a per-flow key (e.g. `pkce_verifier_<state>` where `state` is also passed to `/authorize` and echoed back).
  </Accordion>

  <Accordion title="`state` mismatch after the redirect">
    If your verification rejects a redirect because `state` doesn't match what you stored, treat it as a CSRF attempt — discard the `code` and don't proceed to the exchange. Most often this is a real bug (you forgot to persist `state` across the redirect, or you're reading it from the wrong storage), but it can also indicate that another app on the device intercepted your scheme. Restart the flow with a fresh verifier+state pair.
  </Accordion>
</AccordionGroup>

Append these to **every** request URL — both public catalog calls and authenticated user calls:

```
/endpoint?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```

| Parameter     | Required | Value                                                                                   |
| ------------- | -------- | --------------------------------------------------------------------------------------- |
| `client_id`   | always   | Your `client_id` from [your developer settings](https://simkl.com/settings/developer/). |
| `app-name`    | always   | Short, lowercase identifier for your app (e.g. `plex-scrobbler`, `kodi-trakt-bridge`).  |
| `app-version` | always   | The current version of your app, e.g. `1.0`, `2.4.1`.                                   |

<Tip>
  These three parameters help us see which apps are using the API, debug issues you report, and route around outages. They're cheap to send — please always include them.
</Tip>

## See also

<CardGroup cols={2}>
  <Card title="OAuth 2.0 endpoints" icon="lock" href="/api-reference/oauth">
    `GET /oauth/authorize` and `POST /oauth/token` — both used in this PKCE flow, with parameter combinations specific to public clients.
  </Card>

  <Card title="Choose a flow" icon="route" href="/api-reference/auth">
    Big-picture comparison: OAuth 2.0 (confidential) vs OAuth 2.0 + PKCE (public) vs PIN (browser-less).
  </Card>
</CardGroup>
