If you are building a single-page app or a server-rendered web app, this is the flow you want. The SDKs default to it. You almost never need to think about the wire-level details — but the details matter when something goes wrong, so they live here.
What "PKCE" actually buys you
PKCE (Proof Key for Code Exchange, RFC 7636) is a small protocol addition that makes the authorization-code flow safe to use without a client secret. The browser cannot keep a secret; PKCE replaces the secret with a one-time code_verifier that the SDK generates per sign-in.
The trade is:
The SDK generates a random
code_verifierand acode_challenge = SHA256(code_verifier).It sends the
code_challengewith the initial/oauth2/authorizerequest.When it later exchanges the authorization code for tokens at
/oauth2/token, it includes the originalcode_verifier. The platform recomputes the SHA256, compares to the storedcode_challenge, and refuses the exchange on mismatch.
An attacker who intercepts the authorization code (say, via a malicious browser extension) cannot exchange it for tokens — they do not have the code_verifier. PKCE makes the code single-use to the original session.
The flow, end to end
What the SDK does for you
If you are using @intelliauth/react-sdk or a similar SDK, you get the entire flow above by calling one method:
const { loginWithRedirect } = useIntelliAuth()
await loginWithRedirect()
The SDK:
Generates the
code_verifier+code_challenge.Stores
code_verifierinsessionStorage(cleared on consume).Generates a
stateparameter and stores it alongside.Redirects the browser to
/oauth2/authorize.Handles the
/callbackroute: verifiesstate, exchanges the code, stores tokens.Sets up silent refresh.
You write the "Sign in" button. The SDK writes the rest.
When you would touch the wire format
Three situations:
You are not using an IntelliAuth SDK — for example, you are building a Rust backend and want to do the OAuth dance manually. The wire format below is the contract.
You are debugging a stuck redirect — the network tab shows you the
/oauth2/authorizeURL; understanding its parameters lets you spot mistakes.You are integrating with a third-party tool that drives the flow (a migration shim from a previous identity provider, certain testing harnesses).
Wire-level: send the user's browser to
GET https://<your-tenant-url>/oauth2/authorize
?response_type=code
&client_id=<your-client-id>
&redirect_uri=<your-callback-url>
&scope=openid+profile+email
&state=<random-anti-csrf>
&code_challenge=<base64url-sha256-of-verifier>
&code_challenge_method=S256
The user signs in and consents. On success the platform redirects to <your-callback-url>?code=<auth-code>&state=<the-state-you-sent>.
Then exchange the code from your backend or browser:
POST https://<your-tenant-url>/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=<your-client-id>
&code=<auth-code>
&redirect_uri=<your-callback-url>
&code_verifier=<the-original-verifier>
Response:
{
"access_token": "...",
"id_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}
Common pitfalls
redirect_urimust match exactly. A trailing slash, a different port,httpvshttps— any difference is a refusal. Register every variant you need in the application's settings.stateis not optional. It is your CSRF defence. The SDK generates and verifies it for you; if you implement the flow by hand, do not skip it.code_verifiermust survive the redirect. Stash it insessionStorage(per-tab) rather thanlocalStorage(shared across tabs) so two concurrent sign-ins on the same browser don't trample each other.One code, one exchange. Authorization codes are single-use. If your code retries the
/oauth2/tokencall without a fresh/oauth2/authorize, the second exchange fails.
Native apps and machine-to-machine
Native apps (mobile, desktop) use the same flow with platform-specific browser handoff — see the native-app companion topic. Machine-to-machine integrations (no human present) use client credentials instead, which is a different topic.