Get started
API scopes
Integration guides
Features
Troubleshooting
Expo (React Native)
expo-auth-session is the standard OAuth/OIDC client for Expo apps. It handles PKCE, deep-link redirects, and the in-app browser session.
Use the Native application type in the developer portal — public client, no secret. Mobile apps cannot keep secrets safely.
This requires a development build (expo run:ios / expo run:android). The AuthSession proxy was removed in SDK 48; Expo Go cannot route custom-scheme redirects back into your app, so the sign-in flow dead-ends in the browser. Don't waste an afternoon trying to make Expo Go work.
Install
npx expo install expo-auth-session expo-crypto expo-web-browser expo-secure-store expo-constants
Configuration
Drive everything from environment variables so the developer portal registration and the runtime values can't drift. Expo CLI auto-loads .env since SDK 49.
# .env
KENNI_ISSUER=https://idp.kenni.is/<team-domain>
KENNI_CLIENT_ID=@<team-domain>/native
KENNI_REDIRECT_URI=is.kenni.example://redirect
KENNI_POST_LOGOUT_REDIRECT_URI=is.kenni.example://post-logout
Use a reverse-DNS scheme (is.kenni.example) — myapp collides with every other tutorial app on the device. Register both KENNI_REDIRECT_URI (Redirect URIs) and KENNI_POST_LOGOUT_REDIRECT_URI (Post logout redirect URIs) in the developer portal — they're separate lists with separate lifecycles.
Expo's static app.json can't read env vars, so use a dynamic app.config.ts. Parse the URL scheme out of the redirect URI so there's a single source of truth — drift between expo.scheme and the redirect URI causes silent sign-in failures.
// app.config.ts
import type { ExpoConfig } from 'expo/config';
const required = (key: string): string => {
const value = process.env[key];
if (!value) throw new Error(`Missing env var: ${key}`);
return value;
};
const redirectUri = required('KENNI_REDIRECT_URI');
const scheme = new URL(redirectUri).protocol.replace(':', '');
const config: ExpoConfig = {
name: 'kenni-expo-example',
slug: 'kenni-expo-example',
scheme,
ios: { bundleIdentifier: 'is.kenni.example' },
android: { package: 'is.kenni.example' },
extra: {
issuer: required('KENNI_ISSUER'),
clientId: required('KENNI_CLIENT_ID'),
redirectUri,
postLogoutRedirectUri: required('KENNI_POST_LOGOUT_REDIRECT_URI'),
},
};
export default config;
Read the values at runtime through expo-constants:
// src/config.ts
import Constants from 'expo-constants';
const extra = Constants.expoConfig?.extra ?? {};
export const config = {
issuer: extra.issuer as string,
clientId: extra.clientId as string,
redirectUri: extra.redirectUri as string,
postLogoutRedirectUri: extra.postLogoutRedirectUri as string,
};
Sign-in flow
Run the code exchange from the resolved value of promptAsync(), not from a render-time conditional. A render-time if (response?.type === 'success') { exchangeCodeAsync(...) } re-fires on every subsequent render and the second exchange returns invalid_grant.
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as SecureStore from 'expo-secure-store';
import { config } from './config';
WebBrowser.maybeCompleteAuthSession();
const TOKENS_KEY = 'kenni.tokens.v1';
type Tokens = {
accessToken: string;
idToken: string;
refreshToken?: string;
expiresAt?: number;
};
const decodeIdToken = (idToken: string): Record<string, unknown> => {
const [, payload] = idToken.split('.');
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(globalThis.atob(base64));
};
export const SignIn = () => {
const discovery = AuthSession.useAutoDiscovery(config.issuer);
const [tokens, setTokens] = useState<Tokens | null>(null);
const [ready, setReady] = useState(false);
// Round-trip stored tokens on launch so a relaunch doesn't bounce the user
// back through Kenni when their session is still valid.
useEffect(() => {
SecureStore.getItemAsync(TOKENS_KEY)
.then((value) => value && setTokens(JSON.parse(value)))
.finally(() => setReady(true));
}, []);
const [request, , promptAsync] = AuthSession.useAuthRequest(
{
clientId: config.clientId,
redirectUri: config.redirectUri,
scopes: ['openid', 'profile', 'national_id', 'offline_access'],
usePKCE: true,
},
discovery
);
const signIn = async () => {
if (!discovery || !request) return;
const result = await promptAsync();
if (result.type !== 'success') return;
if (!request.codeVerifier) {
throw new Error('PKCE code_verifier missing — usePKCE must be true');
}
const exchange = await AuthSession.exchangeCodeAsync(
{
clientId: config.clientId,
code: result.params.code,
redirectUri: config.redirectUri,
extraParams: { code_verifier: request.codeVerifier },
},
discovery
);
const next: Tokens = {
accessToken: exchange.accessToken,
idToken: exchange.idToken!,
refreshToken: exchange.refreshToken,
expiresAt: exchange.expiresIn ? Date.now() + exchange.expiresIn * 1000 : undefined,
};
await SecureStore.setItemAsync(TOKENS_KEY, JSON.stringify(next));
setTokens(next);
};
if (!ready) return null;
const profile = tokens ? decodeIdToken(tokens.idToken) : null;
return (
<View>
{profile ? (
<Text>Welcome {String(profile.name ?? profile.sub)}</Text>
) : (
<Button title="Continue with Kenni" disabled={!request} onPress={signIn} />
)}
</View>
);
};
maybeCompleteAuthSession() must run at module scope — it lets expo-auth-session resolve when the OS bounces the user back into the app. Inside a component it gets called too late.
useAutoDiscovery reads /.well-known/openid-configuration once and caches the endpoints. usePKCE: true is the default for public clients but it's worth being explicit, and the runtime check on request.codeVerifier keeps the type-narrowing honest.
Refresh
const refreshed = await AuthSession.refreshAsync(
{
clientId: config.clientId,
refreshToken,
},
discovery
);
Persist refreshed.accessToken and refreshed.refreshToken back into SecureStore.
Sign out
RP-initiated logout has rough UX on iOS that Apple doesn't let you fix. WebBrowser.openAuthSessionAsync opens an ASWebAuthenticationSession which shows a hardcoded "<App> wants to use 'kenni.is' to sign in" consent prompt — even on a sign-out flow. The wording is not configurable. Opening Safari directly (Linking.openURL) trades it for the OS-level "Open this page in '<App>'?" confirmation when Kenni redirects back to the custom scheme. The only clean fix is Universal Links: a real HTTPS domain you control + an apple-app-site-association file + the applinks: entitlement.
What real iOS apps ship: skip RP-initiated logout entirely on iOS. Clear local tokens and force prompt=login on the next sign-in. Android Chrome Custom Tabs have neither prompt and the full flow is clean.
import { Platform } from 'react-native';
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as SecureStore from 'expo-secure-store';
const signOut = async (discovery: AuthSession.DiscoveryDocument, tokens: Tokens) => {
// Clear local state first — a failed RP-initiated logout still leaves you signed out locally.
await SecureStore.deleteItemAsync(TOKENS_KEY);
if (tokens.refreshToken) {
await AuthSession.revokeAsync({ token: tokens.refreshToken, clientId: config.clientId }, discovery).catch(
() => {}
);
}
if (Platform.OS === 'ios') {
// Skip RP-initiated logout. Force `prompt=login` on the next authorize request instead.
return;
}
if (!discovery.endSessionEndpoint) return;
const url = new URL(discovery.endSessionEndpoint);
url.searchParams.set('id_token_hint', tokens.idToken);
url.searchParams.set('post_logout_redirect_uri', config.postLogoutRedirectUri);
url.searchParams.set('client_id', config.clientId);
await WebBrowser.openAuthSessionAsync(url.toString(), config.postLogoutRedirectUri);
};
The second argument to openAuthSessionAsync is the URI the WebBrowser detects to close itself — it must equal post_logout_redirect_uri, and it must be a different value from your sign-in redirectUri so the OS deep-link layer can tell sign-in completion apart from sign-out completion.
Then on the next sign-in, force re-authentication on iOS so the local sign-out doesn't feel fake:
const [request, , promptAsync] = AuthSession.useAuthRequest(
{
clientId: config.clientId,
redirectUri: config.redirectUri,
scopes: ['openid', 'profile', 'national_id', 'offline_access'],
usePKCE: true,
extraParams: forceReauth ? { prompt: 'login' } : undefined,
},
discovery
);
Set forceReauth = true after signOut, clear it after a successful sign-in.
revokeAsync invalidates the refresh token server-side. Skipping it leaves a valid refresh token in SecureStore after sign-out — if cleared local state is your only defence and the device is later compromised, the token still works.
See the No framework (curl) guide for the protocol-level reasoning.