Skip to main content
The session token exchange flow enables native applications to obtain session tokens after completing browser-based authentication flows. This is essential for mobile and desktop apps that use system browsers for login.

Why token exchange?

Native applications cannot use HTTP-only cookies for session management because:
  • System browsers don’t share cookies with the app
  • Embedded webviews are insecure and deprecated by OAuth providers
  • Native apps need to make authenticated API requests
Solution: The token exchange flow allows apps to exchange a one-time code for a session token after authentication.

Flow overview

1

Initialize flow

The native app starts a registration or login flow and receives an init_code.
2

Open system browser

The app opens the system browser with the flow URL and includes a return_to parameter pointing to a custom URL scheme.
3

User authenticates

User completes authentication in the browser.
4

Return to app

Browser redirects to the custom URL scheme with a return_to_code.
5

Exchange codes

App exchanges both codes for a session token via the token exchange endpoint.
6

Store token

App stores the token securely and uses it for authenticated requests.

Implementation

Step 1: Initialize the flow

Create a login or registration flow via API:
curl -X GET "https://kratos-public/self-service/login/api" \
  -H "Accept: application/json"
Response includes init_code:
{
  "id": "9f425a8d-7efc-4768-8f23-7647a74fdf13",
  "type": "api",
  "session_token_exchange_code": "ory_code_init_abc123...",
  "ui": {
    "action": "https://kratos-public/self-service/login?flow=9f425a8d...",
    "nodes": [...]
  }
}

Step 2: Open system browser

Construct the browser URL with your custom URL scheme:
import { Linking } from 'react-native';
import InAppBrowser from 'react-native-inappbrowser-reborn';

const initCode = flow.session_token_exchange_code;
const returnToUrl = 'myapp://auth/callback';
const flowUrl = `${flow.ui.action}&return_to=${encodeURIComponent(returnToUrl)}`;

// Open system browser
await InAppBrowser.open(flowUrl, {
  dismissButtonStyle: 'close',
  preferredBarTintColor: '#453AA4',
  preferredControlTintColor: 'white',
  readerMode: false,
  animated: true,
  modalEnabled: true,
  enableBarCollapsing: false,
});

Step 3: Handle the callback

Register a custom URL scheme and handle the redirect:
import { Linking } from 'react-native';
import { parse } from 'query-string';

// Listen for deep links
Linking.addEventListener('url', async (event) => {
  const { url } = event;
  
  if (url.startsWith('myapp://auth/callback')) {
    // Close browser
    await InAppBrowser.close();
    
    // Extract return_to_code
    const queryParams = parse(url.split('?')[1]);
    const returnToCode = queryParams.return_to_code;
    
    // Exchange codes for session token
    await exchangeCodesForSession(initCode, returnToCode);
  }
});

Step 4: Exchange codes for session token

Call the token exchange endpoint:
GET /sessions/token-exchange?init_code={init_code}&return_to_code={return_to_code}
async function exchangeCodesForSession(initCode: string, returnToCode: string) {
  const response = await fetch(
    `https://kratos-public/sessions/token-exchange?` +
    `init_code=${encodeURIComponent(initCode)}&` +
    `return_to_code=${encodeURIComponent(returnToCode)}`
  );

  if (!response.ok) {
    throw new Error('Token exchange failed');
  }

  const data = await response.json();
  
  // Store session token securely
  await secureStorage.setItem('session_token', data.session_token);
  
  // Session object is also available
  console.log('Logged in as:', data.session.identity.traits.email);
  
  return data;
}

Response format

{
  "session_token": "ory_st_MP2YWEMeM8MxjkGKpH4dqOQ4Q4DlSPaj",
  "session": {
    "id": "9f425a8d-7efc-4768-8f23-7647a74fdf13",
    "active": true,
    "expires_at": "2024-02-15T09:30:00Z",
    "authenticated_at": "2024-01-15T09:30:00Z",
    "authenticator_assurance_level": "aal1",
    "authentication_methods": [
      {
        "method": "password",
        "aal": "aal1",
        "completed_at": "2024-01-15T09:30:00Z"
      }
    ],
    "issued_at": "2024-01-15T09:30:00Z",
    "identity": {
      "id": "7b9f3e2a-5c1d-4f8e-9a3b-2d6c8e4f7a9b",
      "traits": {
        "email": "[email protected]"
      }
    }
  }
}

Using the session token

Once obtained, use the token for authenticated requests:
curl "https://kratos-public/sessions/whoami" \
  -H "Authorization: Bearer ory_st_MP2YWEMeM8MxjkGKpH4dqOQ4Q4DlSPaj"

Code storage and expiry

The token exchange mechanism uses a database table:
selfservice/sessiontokenexchange/persistence.go
type Exchanger struct {
    ID           uuid.UUID
    NID          uuid.UUID
    FlowID       uuid.UUID
    SessionID    uuid.NullUUID
    InitCode     string
    ReturnToCode string
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

Code lifecycle

1

Flow initialization

init_code and return_to_code are generated and stored.
2

Flow completion

When the flow completes, session_id is attached to the exchanger.
3

Token exchange

Both codes are validated, session token is returned, exchanger can be reused.
4

Expiration

Exchangers are deleted after a configured TTL (typically 10 minutes).
Codes are single-use for flow initialization but can be exchanged multiple times after the session is created. Store the session token securely after first exchange.

Error handling

Common errors

{
  "error": {
    "id": "forbidden",
    "code": 403,
    "status": "Forbidden",
    "reason": "no session yet for this code"
  }
}
Causes:
  • Invalid init_code or return_to_code
  • User hasn’t completed the flow yet
  • Codes have expired
{
  "error": {
    "id": "not_found",
    "code": 404,
    "status": "Not Found",
    "reason": "no session yet for this code"
  }
}
Causes:
  • Flow was not completed successfully
  • Session was deleted before exchange
{
  "error": {
    "id": "bad_request",
    "code": 400,
    "status": "Bad Request",
    "reason": "init_code and return_to_code query params must be set"
  }
}

Security considerations

Security best practices:
  • Use HTTPS for all requests
  • Store session tokens in secure storage (Keychain/Keystore)
  • Never log session tokens
  • Use system browser (not webview) for authentication
  • Implement PKCE if building custom OAuth flows
  • Validate the return URL scheme to prevent hijacking
  • Set short expiry times for exchange codes

Custom URL schemes

Register your custom URL scheme:
<activity android:name=".MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="auth" />
    </intent-filter>
</activity>

Complete example

Here’s a full React Native implementation:
Complete React Native Flow
import { useEffect, useState } from 'react';
import { Linking } from 'react-native';
import InAppBrowser from 'react-native-inappbrowser-reborn';
import * as SecureStore from 'expo-secure-store';

const KRATOS_URL = 'https://kratos-public';
const RETURN_TO_URL = 'myapp://auth/callback';

export function useAuth() {
  const [session, setSession] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Handle deep links
    const subscription = Linking.addEventListener('url', handleDeepLink);
    return () => subscription.remove();
  }, []);

  async function handleDeepLink(event) {
    const { url } = event;
    
    if (url.startsWith(RETURN_TO_URL)) {
      await InAppBrowser.close();
      
      const params = new URLSearchParams(url.split('?')[1]);
      const returnToCode = params.get('return_to_code');
      const initCode = await SecureStore.getItemAsync('init_code');
      
      if (initCode && returnToCode) {
        await exchangeCodesForSession(initCode, returnToCode);
      }
    }
  }

  async function startLogin() {
    setLoading(true);
    
    try {
      // Initialize login flow
      const response = await fetch(`${KRATOS_URL}/self-service/login/api`, {
        headers: { 'Accept': 'application/json' }
      });
      const flow = await response.json();
      
      // Store init code
      await SecureStore.setItemAsync('init_code', flow.session_token_exchange_code);
      
      // Open browser
      const flowUrl = `${flow.ui.action}&return_to=${encodeURIComponent(RETURN_TO_URL)}`;
      await InAppBrowser.open(flowUrl);
    } catch (error) {
      console.error('Login failed:', error);
      setLoading(false);
    }
  }

  async function exchangeCodesForSession(initCode, returnToCode) {
    try {
      const response = await fetch(
        `${KRATOS_URL}/sessions/token-exchange?` +
        `init_code=${encodeURIComponent(initCode)}&` +
        `return_to_code=${encodeURIComponent(returnToCode)}`
      );
      
      const data = await response.json();
      
      // Store session token
      await SecureStore.setItemAsync('session_token', data.session_token);
      
      setSession(data.session);
      setLoading(false);
    } catch (error) {
      console.error('Token exchange failed:', error);
      setLoading(false);
    }
  }

  async function logout() {
    await SecureStore.deleteItemAsync('session_token');
    setSession(null);
  }

  return { session, loading, startLogin, logout };
}

Build docs developers (and LLMs) love