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.

Next steps