With expo-auth-session, how do useAuthRequest, promptAsync and exchangeCodeAsync fit together in an authorization-code sign-in with PKCE?
answer
- hook returns request, response, promptAsync
- request is null until it has loaded
- usePKCE defaults to true, method S256
- state checked for you: state_mismatch
- pass request.codeVerifier as code_verifier
basics
~10 suseAuthRequest builds the request (state plus a PKCE verifier and S256 challenge) and returns [request, response, promptAsync]. promptAsync opens the system browser; on success you pass response.params.code and request.codeVerifier to exchangeCodeAsync to get tokens.
solid answer
~30 s`useAuthRequest(config, discovery)` returns `[request, response, promptAsync]`. `request` is `null` until the hook has built the authorization URL, including a random `state` and, because `usePKCE` defaults to `true`, a code verifier with an `S256` challenge, so the sign-in button stays disabled until then. `promptAsync()` opens the provider through `expo-web-browser` and sets `response`; the hook compares `state` itself and reports a mismatch as an `error` with `state_mismatch`. On `type: 'success'` you call `exchangeCodeAsync({ clientId, code: response.params.code, redirectUri, extraParams: { code_verifier: request.codeVerifier } }, discovery)`. The verifier is **not** added automatically, and a client secret never belongs in the app.
code
tsx · 41 linesimport { useEffect } from 'react';
import { Button } from 'react-native';
import {
exchangeCodeAsync,
makeRedirectUri,
useAuthRequest,
useAutoDiscovery,
type TokenResponse,
} from 'expo-auth-session';
const clientId = 'expenses-mobile';
const redirectUri = makeRedirectUri({ scheme: 'expenses', path: 'auth' });
export function CorporateSignIn({ onTokens }: { onTokens: (tokens: TokenResponse) => void }) {
const discovery = useAutoDiscovery('https://sso.example.com');
const [request, response, promptAsync] = useAuthRequest(
{ clientId, redirectUri, scopes: ['openid', 'profile', 'offline_access'] },
discovery,
);
useEffect(() => {
if (response?.type !== 'success' || !request?.codeVerifier || !discovery) return;
exchangeCodeAsync(
{
clientId,
redirectUri,
code: response.params.code,
extraParams: { code_verifier: request.codeVerifier },
},
discovery,
)
.then(onTokens)
.catch(() => {
// show a retry message; the code is single-use
});
}, [response, request, discovery, onTokens]);
return (
<Button title="Sign in with your company account" disabled={!request} onPress={() => promptAsync()} />
);
}go deeper
Recall the tuple the hook returns and that the button stays disabled until request is loaded.
Walk through the flow end to end: defaults for PKCE and state, the success response, and the exchange with code_verifier and the same redirect URI.
Debug failed exchanges and double prompts, and decide when the code exchange must move to your backend because the provider wants a secret.
Choose the sign-in stack for several apps: expo-auth-session versus a native AppAuth wrapper, and a shared backend exchange versus per-app public clients.
## The three pieces `expo-auth-session` splits the authorization-code flow into three steps you wire together: 1. **`useAuthRequest(config, discovery)`**: a hook that builds an `AuthRequest` from your config (client ID, redirect URI, scopes) and the provider's endpoints. It returns `[request, response, promptAsync]`. 2. **`promptAsync(options?)`**: opens the authorization URL in the system browser through `expo-web-browser`'s `openAuthSessionAsync`, waits for the redirect, parses it, stores it in `response` and also returns it. 3. **`exchangeCodeAsync(config, discovery)`**: sends the one-time `code` to the provider's token endpoint and resolves with a `TokenResponse` (`accessToken`, optional `refreshToken`, `idToken`, expiry). The endpoints come from a **discovery document**. For an OpenID Connect provider, `useAutoDiscovery(issuerUrl)` fetches it and returns `null` until it has loaded; for other providers you pass an object with `authorizationEndpoint` and `tokenEndpoint` yourself. ## What the hook does for you - **`state`**: if you do not pass one, the request generates a random value and, when the redirect comes back, compares it with the returned `state`. A mismatch yields `response.type === 'error'` with the error code `state_mismatch`. - **PKCE**: `usePKCE` defaults to `true` and `codeChallengeMethod` to `S256`. The request creates a random code verifier, sends only its challenge in the authorization URL, and keeps the verifier on `request.codeVerifier`. - **Loading**: `request` is `null` while the URL is being built (the PKCE hash is computed asynchronously) and while `discovery` is `null`. Calling `promptAsync` before then throws, which is why Expo's guide says to disable the button until `request` exists. - **One session at a time**: a second `promptAsync` while one is open resolves `{ type: 'locked' }`. ## What you must still do - **Pass the verifier to the exchange.** `exchangeCodeAsync` knows nothing about the hook; add `extraParams: { code_verifier: request.codeVerifier }`. - **Reuse the exact redirect URI.** The token request requires `redirectUri`, and it must match the one used in the authorization request. - **Keep secrets out of the app.** The library's own docs note there is no secure way to store a `clientSecret` on the client. If the corporate identity provider insists on a confidential client, exchange the code on your server; Expo's guide recommends exactly that. | Provider registration | Where the code exchange happens | |---|---| | public or native client, PKCE, no secret | in the app with `exchangeCodeAsync` | | client secret required | on your backend, which receives the code (and verifier) from the app | ## Reading the response | `response.type` | Meaning | |---|---| | `success` | `params.code` is ready to exchange | | `error` | the provider returned an error, or `state` did not match | | `cancel` / `dismiss` | the user backed out of the browser | | `locked` | another auth session was already running | ## Debugging a failed exchange When the provider answers the token request with an error although the user signed in fine, check in this order: 1. `extraParams.code_verifier` is the `codeVerifier` of the **same** request that produced the challenge; recreating the request between renders produces a new verifier. 2. `redirectUri` is the identical string used in the authorization request. 3. The code is used **once**; a retry after a network error needs a new sign-in. 4. The client ID matches a public, PKCE-enabled registration rather than one that expects a secret. ## Refreshing later The same package covers the rest of the token lifecycle. `refreshAsync(config, discovery)` trades a refresh token for new tokens at the token endpoint, and a `TokenResponse` offers `shouldRefresh()` (with the static `TokenResponse.isTokenFresh()`) to decide when that is needed. If the provider returned no `refresh_token`, the user has to sign in again when the access token expires, which is why corporate apps often request an offline-access scope. ## The alternative: react-native-app-auth `react-native-app-auth` is a native-module library that wraps the AppAuth SDKs for iOS and Android and performs the whole flow, PKCE and token exchange included, in native code. It suits bare projects or teams that want the reference native implementation; in an Expo project, `expo-auth-session` is the integrated default and works with a development build. Either way, where the tokens end up afterwards is a storage decision, made separately.
- Why does `request` start out as `null`, and what happens if you ignore that?The hook builds the authorization URL asynchronously, including hashing the PKCE verifier, and it cannot start until the discovery document has loaded. Calling `promptAsync` before `request` exists throws an error, so the sign-in button should be disabled until then.
- The corporate identity provider rejects the token request with an invalid-grant error, though the code is fresh. What do you check first?That `extraParams.code_verifier` is the same `request.codeVerifier` that produced the challenge, and that `redirectUri` is byte-for-byte the one used in the authorization request. Both are easy to lose when the request object is recreated between renders or the redirect is rebuilt with different options.
- When would you pick `react-native-app-auth` over `expo-auth-session`?In a bare project that wants the native AppAuth implementation, or when a provider works better with a fully native flow. In an Expo project `expo-auth-session` is integrated with the rest of the SDK and is usually the simpler choice.
saying these in an interview costs you the question
- exchangeCodeAsync picks up the PKCE verifier from the hook automatically.
- PKCE must be switched on explicitly in useAuthRequest.
- The client secret can ship in the app if the bundle is minified.
- promptAsync can be called as soon as the component mounts.
- A state mismatch is something the app must detect itself.