Skip to Content
🔐 Faable AuthOAuth 2.0 FlowsAuthorization Code (PKCE)

Authorization Code Flow with PKCE 🔐

The OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange) is the standard way to log users in. The user authenticates on your Faable Auth domain, your app receives a one-time code, and exchanges it for tokens — an access token to call your APIs, an ID token with the user’s identity, and a refresh token to keep the session alive.

PKCE adds a cryptographic proof that ties the code to the client that started the flow, so it’s safe even for apps that cannot keep a secret:

  • ✅ Single Page Applications (React, Vue, Angular)
  • ✅ Native and mobile apps (iOS, Android, React Native)
  • ✅ Server-side web apps (Next.js, Express)

For backend services calling APIs without a user, use the Client Credentials flow instead.


🔑 What is PKCE, and how does it work?

PKCE (Proof Key for Code Exchange, RFC 7636  — pronounced “pixy”) is an extension to the OAuth 2.0 Authorization Code flow that makes it safe for clients that cannot keep a secret — single-page apps, mobile, and native apps whose code ships to the user’s device.

It closes the authorization-code interception attack. Without PKCE, anything that captures the code on its way back to your app — a malicious app registered on the same mobile URL scheme, a leaky proxy, browser history — could exchange it for tokens. PKCE ties the code to the exact client that started the flow:

  1. Your app generates a random secret, the code_verifier, and keeps it locally.
  2. It sends only the code_challengebase64url(SHA-256(code_verifier)) — when it redirects to /authorize. The hash is one-way, so the challenge is useless to an attacker.
  3. When exchanging the code for tokens, your app sends the original code_verifier. Faable recomputes the hash and rejects the exchange unless it matches the challenge from step 2.

A stolen code is worthless without the code_verifier, which never left your app. That is why PKCE is now the recommended practice for every client type — including confidential, server-side apps.

Authorization Code + PKCE vs other flows

Your appUse
SPA, mobile, native, or server-side web app logging a user inAuthorization Code + PKCE (this page)
Backend service / cron / CI calling an API with no userClient Credentials
Renewing an access token without re-loginRefresh Token

The older Implicit flow (tokens returned in the URL fragment) is deprecated — never put tokens in URLs. Authorization Code + PKCE replaces it for public clients.


📸 How It Works

The code is single-use and short-lived. Even if it’s intercepted, it’s useless without the code_verifier — which never left your app.


✅ Prerequisites

From the Faable Dashboard :

  1. Create a Client for your application — this gives you the client_id. See Clients.
  2. Add your callback URL (e.g. https://your-app.com/callback) to the client’s Allowed Callback URLs. Redirects to unlisted URLs are rejected.

🚀 Quick Start with @faable/auth-js

The fastest path: our SDK generates the PKCE verifier, handles the redirect, exchanges the code, stores the session, and auto-refreshes tokens.

import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID' }) // 1. Start the login — generates PKCE values and redirects to Faable await auth.signInWithOauthConnection({ redirectTo: 'https://your-app.com/callback' })

On your callback page, complete the exchange and read the session:

// 2. On /callback — exchanges the code for tokens and stores the session await auth.initialize() // 3. Anywhere in your app const { data } = await auth.getSession() console.log(data.session?.user)

That’s it — the SDK also refreshes the access token transparently when it expires, using the Refresh Token flow.

[!IMPORTANT] The redirectTo URL must be listed in the client’s Allowed Callback URLs in the dashboard, or the request will be rejected.

Using a framework? Follow a copy-paste quickstart instead: React · Next.js · Vue · SvelteKit · Angular · JavaScript (vanilla) · React Native.


🧩 How to Generate the code_verifier and code_challenge

If you use @faable/auth-js, skip this — the SDK generates, stores, and sends these for you. Rolling your own? Here’s the exact PKCE pair generation.

The code_verifier is a high-entropy random string (43–128 chars from the unreserved set). The code_challenge is its SHA-256 hash, base64url-encoded — code_challenge_method=S256, the only method Faable supports.

Browser (Web Crypto API):

function base64url(bytes: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(bytes))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } // code_verifier — 32 random bytes → base64url (~43 chars) const codeVerifier = base64url( crypto.getRandomValues(new Uint8Array(32)).buffer ) // code_challenge = base64url(SHA-256(code_verifier)) const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier) ) const codeChallenge = base64url(digest)

Node.js:

import { createHash, randomBytes } from 'node:crypto' const codeVerifier = randomBytes(32).toString('base64url') const codeChallenge = createHash('sha256') .update(codeVerifier) .digest('base64url')

Keep the code_verifier until the callback — it must survive the redirect (e.g. sessionStorage in a SPA). Send the code_challenge on /authorize, then send the original code_verifier back on the token exchange.


🛠️ Step-by-Step over HTTP

Implementing it yourself, or curious what the SDK does under the hood? The whole flow is two requests.

Step 1: Redirect the User to /authorize

Generate a random code_verifier, derive the challenge as base64url(sha256(code_verifier)), and redirect the browser to:

  • Endpoint: https://your-domain.auth.faable.link/authorize
  • Method: GET (browser redirect)
https://your-domain.auth.faable.link/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://your-app.com/callback &scope=openid profile email &state=RANDOM_OPAQUE_VALUE &code_challenge=BASE64URL_SHA256_OF_VERIFIER &code_challenge_method=S256
ParameterRequiredDescription
response_typeYesMust be code.
client_idYesYour application’s Client ID.
redirect_uriYesWhere to send the user after login. Must be in the client’s Allowed Callback URLs.
scopeRecommendedSpace-separated. Use openid profile email to get an ID token with the user’s profile.
stateRecommendedOpaque random value echoed back on the callback. Verify it matches to prevent CSRF.
code_challengeRecommendedbase64url(sha256(code_verifier)). Enables PKCE.
code_challenge_methodRecommendedOnly S256 is supported.
audienceNoIdentifier of the API the access token should target (sets its aud claim).
connection_idNoWhich connection to use (e.g. a specific social provider). Defaults to the tenant’s default connection.
nonceNoOIDC replay protection — echoed in the issued id_token.
promptNoControls the login UI — see Controlling the prompt.

Step 2: The User Logs In

Faable shows your tenant’s login screen and the user authenticates with any connection you’ve enabled — email/password, passwordless OTP, Google, GitHub, or a custom provider.

Step 3: Receive the Code on Your Callback

On success, the browser lands back on your app:

https://your-app.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_OPAQUE_VALUE

Verify that state matches the value you sent in Step 1. On failure (e.g. the user cancels, or prompt=none needed interaction), the redirect carries an error instead:

https://your-app.com/callback?error=access_denied&error_description=...&state=...

Step 4: Exchange the Code for Tokens

From your app, POST the code to the token endpoint together with the original code_verifier:

  • Endpoint: https://your-domain.auth.faable.link/oauth/token
  • Content-Type: application/x-www-form-urlencoded or application/json
curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/token' \ --header 'content-type: application/x-www-form-urlencoded' \ --data 'grant_type=authorization_code' \ --data 'client_id=YOUR_CLIENT_ID' \ --data 'code=AUTHORIZATION_CODE' \ --data 'code_verifier=ORIGINAL_CODE_VERIFIER' \ --data 'redirect_uri=https://your-app.com/callback'

Response

{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "v1.MRjD...", "token_type": "Bearer", "expires_in": 86400 }
FieldDescription
access_tokenSigned JWT (RS256) for calling your APIs. Send it as Authorization: Bearer <token>.
id_tokenOIDC JWT with the user’s identity claims (sub, email, name, …).
refresh_tokenUse it to obtain new access tokens without re-authenticating — see Refresh Token flow.
expires_inAccess token lifetime in seconds, controlled by the target API’s token_lifetime (default: 86400 = 24 h).

[!NOTE] The authorization code is single-use — it is consumed and invalidated on the first exchange. A second exchange with the same code fails.


🎛️ Controlling the Prompt

The optional prompt parameter on /authorize controls whether the user sees the login UI. It accepts a space-separated list:

ValueBehavior
noneSSO probe. No UI is ever displayed; the request errors if interaction would be needed. Must be the only value. Useful for silent re-authentication from a SPA.
loginForces the user to re-authenticate, even with an active session.
consentForces the consent step before issuing tokens. Currently behaves like login (a dedicated consent screen is on the roadmap).
select_accountAccepted for compatibility but currently ignored.

Unknown prompt values are accepted silently. Most applications can leave prompt unset.


⚠️ Common Errors

Errors on /authorize are returned to your redirect_uri as ?error=...&error_description=... query parameters. Errors on the token exchange return an OAuth 2.0 error body ({ "error": "...", "error_description": "..." }):

WhereErrorCause / Fix
/authorizeCallback URL rejectedThe redirect_uri is not in the client’s Allowed Callback URLs. Add it in the dashboard.
/authorizelogin_required with prompt=noneNo active session and interaction was forbidden. Fall back to an interactive login.
Token 400Missing or already-used codeCodes are single-use and short-lived. Restart the flow to get a fresh one.
Token 400Code Verifier is not validThe code_verifier doesn’t match the code_challenge sent at /authorize. Use the exact original value.
Token 401Bad client_idThe code was issued to a different client. Use the same client_id in both steps.

🔒 Security Best Practices

  • Always use PKCE, even in server-side apps. It’s the current OAuth 2.0 best practice for every client type.
  • Always send and verify state. It’s your CSRF protection on the callback.
  • Never put tokens in URLs. The code flow exists precisely so tokens travel in a POST response body, not in the address bar or browser history.
  • Register exact callback URLs. Prefer full paths (https://app.com/auth/callback) over broad patterns.
  • Let the SDK store the session. @faable/auth-js handles storage, rotation, and cross-tab sync for you.

❓ FAQ

Do I need a client_secret for this flow?

No. PKCE replaces the secret for public clients (SPAs, mobile). That’s the point: the flow is secure without shipping any secret to the browser.

What is PKCE, in one sentence?

Your app invents a random secret (code_verifier), sends only its hash when the flow starts, and reveals the secret when exchanging the code — proving both requests came from the same app.

Does this flow return a refresh token?

Yes. Use it with the Refresh Token flow to renew access tokens silently. The @faable/auth-js SDK does this automatically.

How do users actually log in?

With whatever connections you enable on the client: email/password, passwordless OTP, Google, GitHub, or custom OIDC providers. The flow is identical for all of them.

When should I use Client Credentials instead?

When there is no user — a backend service, cron job, or CI pipeline calling an API. See Client Credentials.

How do I generate a code_verifier and code_challenge?

The verifier is a random high-entropy string; the challenge is base64url(SHA-256(verifier)). See How to Generate the code_verifier and code_challenge for copy-paste browser and Node snippets — or let @faable/auth-js do it for you.

Is PKCE required?

For public clients (SPA, mobile, native) it’s mandatory in practice — the flow is unsafe without it. For confidential server-side clients it’s strongly recommended and the current OAuth 2.0 best practice. Faable supports it for every client type.

What is code_challenge_method=S256?

It tells the server the challenge is the SHA-256 hash of the verifier (not the plaintext). S256 is the only method Faable accepts — plain challenges are rejected.

Does PKCE work with refresh tokens?

Yes. The code exchange returns a refresh_token alongside the access token; renew silently with the Refresh Token flow. @faable/auth-js refreshes for you automatically.


Last updated on