OAuth2 PKCE Authentication with AWS Cognito and Capacitor

OAuth2 PKCE Authentication with AWS Cognito and Capacitor
Photo by Tech Daily / Unsplash

Back in 2021, I wrote about processing an AWS Cognito Authorization Code Grant using AWS Amplify. That approach worked, but it involved calling a private TypeScript method (Auth._oAuthHandler.handleAuthResponse()), manually constructing CognitoUserSession objects, and registering them with Amplify via setSignInUserSession(). It was fragile, undocumented, and guaranteed to break with Amplify updates.

When I rebuilt the Cork Hounds mobile app on Capacitor 8, I decided to skip Amplify entirely and implement the OAuth2 PKCE flow directly against Cognito's standard OAuth2 endpoints. The result is ~570 lines of TypeScript that handle the complete authentication lifecycle — login, logout, token refresh, session persistence — with zero Amplify dependencies, no private APIs, and full compliance with RFC 7636 (PKCE) and RFC 6749 (OAuth2).

In this post, I'll walk through why Amplify doesn't work well for hybrid mobile apps, how OAuth2 PKCE works, and the complete implementation.

Why Amplify Doesn't Work for Capacitor/Cordova Apps

Amplify's Auth module is designed for web apps. It expects to control the entire OAuth redirect lifecycle through its internal urlListener, which monitors the browser's window.location for redirect URLs. In a hybrid mobile app built with Cordova or Capacitor, the redirect from Cognito's hosted UI comes back as a custom URL scheme — something like com.corkhounds.mobile://callback?code=xxx&state=yyy. Amplify can't intercept these.

In my original implementation, I worked around this by:

  1. Calling Auth._oAuthHandler.handleAuthResponse() — a private, undocumented method — to parse the authorization code
  2. Manually constructing CognitoAccessToken, CognitoIdToken, and CognitoRefreshToken objects
  3. Wrapping them in a CognitoUserSession
  4. Creating a CognitoUser and calling setSignInUserSession() to register the session with Amplify

This worked, but it depended on Amplify internals that could change at any time. The amazon-cognito-auth-js package that originally handled this flow was archived by AWS, and the replacement code in Amplify was marked as private. Every Amplify update was a risk.

There's also the bundle size issue. Amplify pulls in a significant amount of code — the Auth module alone brings along the entire Cognito Identity SDK. For a mobile app where startup time matters, this is a real cost.

The Better Way: Direct OAuth2 PKCE

Instead of fighting Amplify, we can implement the standard OAuth2 Authorization Code flow with PKCE directly against Cognito's OAuth2 endpoints. This is the same protocol that Amplify uses internally, but without the abstraction layers, private APIs, or bundle bloat.

PKCE (Proof Key for Code Exchange, pronounced "pixie") is an extension to the OAuth2 Authorization Code flow designed for public clients — like mobile apps — that can't securely store a client secret. Here's how it works:

The Flow

  1. Generate a secret — Create a random code_verifier (a high-entropy string) and derive a code_challenge from it using SHA-256
  2. Open the login page — Redirect the user to Cognito's hosted UI, passing the code_challenge (but NOT the verifier)
  3. User authenticates — Cognito handles the login form, social sign-on, MFA, etc.
  4. Receive the redirect — Cognito redirects back to our custom URL scheme with an authorization code
  5. Exchange the code — POST the authorization code AND the code_verifier to Cognito's token endpoint
  6. Cognito validates — It computes SHA256(code_verifier) and checks it matches the original code_challenge. If it matches, tokens are issued.

The security of PKCE comes from step 5: even if an attacker intercepts the authorization code (step 4), they can't exchange it for tokens because they don't have the code_verifier. Only the app that initiated the flow has it.

Implementation

The implementation consists of three files:

  • src/auth/cognito-oauth.ts — Core OAuth2 PKCE module (~570 lines)
  • src/hooks/useAuth.ts — React hook for components (~190 lines)
  • src/store/index.ts — Redux integration for API calls

Prerequisites

Cognito User Pool Configuration:

  • App Client: Public client (no client secret)
  • Callback URL: com.corkhounds.mobile://callback
  • Sign-out URL: com.corkhounds.mobile://signout
  • OAuth grant type: Authorization Code Grant
  • OAuth scopes: email, profile, openid

iOS Configuration (ios/App/App/Info.plist):

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.corkhounds.mobile</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.corkhounds.mobile</string>
    </array>
  </dict>
</array>

Dependencies — only two Capacitor plugins, no AWS SDKs:

{
  "@capacitor/browser": "^8.0.3",
  "@capacitor/preferences": "^8.0.1"
}

Compare this to the Amplify approach, which required aws-amplify, amazon-cognito-identity-js, and @aws-sdk/client-cognito-identity-provider.

Step 1: PKCE Code Generation

The PKCE protocol requires two values: a code_verifier (random secret) and a code_challenge (SHA-256 hash of the verifier). We generate these using the Web Crypto API, which is available in both browser and Capacitor WebView environments:

function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}
​
async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(digest));
}
​
function base64UrlEncode(buffer: Uint8Array): string {
  let binary = '';
  for (let i = 0; i < buffer.length; i++) {
    binary += String.fromCharCode(buffer[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

The base64url encoding follows RFC 4648 Section 5 — URL-safe characters with no padding. This is important because these values are passed as URL query parameters.

Step 2: Initiating Login

Login opens Cognito's hosted UI in the system browser using Capacitor's Browser plugin. We build the /oauth2/authorize URL with all the required OAuth2 parameters:

export async function initiateLogin(config: CognitoOAuthConfig):

Promise<string> {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = generateState();
​
  // Store the verifier — we'll need it when exchanging the code for tokens
  await Preferences.set({ key: STORAGE_KEYS.CODE_VERIFIER, value: codeVerifier });
​
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scopes.join(' '),
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });
​
  const authorizeUrl = `https://${config.domain}/oauth2/authorize?${params.toString()}`;
  await Browser.open({ url: authorizeUrl, windowName: '_self' });
​
  return state;
}

The state parameter is a random value used to prevent CSRF attacks. We return it so the caller can verify it when the redirect comes back.

The code_verifier is stored on-device using Capacitor Preferences. It's single-use — we retrieve it during the token exchange and then delete it.

Step 3: Handling the Redirect

When the user authenticates, Cognito redirects to our custom URL scheme. Capacitor fires an appUrlOpen event that we intercept in the useAuth hook:

useEffect(() => {
  const listener = CapApp.addListener('appUrlOpen', async (event) => {
    if (!event.url.startsWith('com.corkhounds.mobile://callback')) return;
​
    const tokens = await handleRedirect(
      event.url,
      expectedStateRef.current ?? '',
      oauthConfig,
    );
​
    const userInfo = extractUserInfo(tokens.idToken);
    setUser(userInfo);
    setIsLoggedIn(true);
    setJwt(tokens.accessToken);
    dispatch(setAuth({ jwt: tokens.accessToken, email: userInfo.email, fullname: userInfo.name }));
  });
​
  return () => { listener.then((l) => l.remove()); };
}, []);

The handleRedirect function does the heavy lifting — it parses the URL, verifies the state, retrieves the stored code verifier, and exchanges the authorization code for tokens:

export async function handleRedirect(
  url: string,
  expectedState: string,
  config: CognitoOAuthConfig,
): Promise<OAuthTokens> {
  const urlObj = new URL(url);
  const code = urlObj.searchParams.get('code');
  const state = urlObj.searchParams.get('state');
​
  if (state !== expectedState) {
    throw new Error('OAuth state mismatch — possible CSRF attack');
  }
​
  const { value: codeVerifier } = await Preferences.get({ key: STORAGE_KEYS.CODE_VERIFIER });
​
  const tokens = await exchangeCodeForTokens(code, codeVerifier, config);
​
  await Preferences.remove({ key: STORAGE_KEYS.CODE_VERIFIER });
  await storeTokens(tokens);
  await Browser.close();
​
  return tokens;
}

Step 4: Token Exchange

The token exchange is a standard OAuth2 POST to Cognito's /oauth2/token endpoint. This is where PKCE proves its value — we send the code_verifier that only our app possesses:

async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string,
  config: CognitoOAuthConfig,
): Promise<OAuthTokens> {
  const tokenUrl = `https://${config.domain}/oauth2/token`;
​
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: config.redirectUri,
    client_id: config.clientId,
    code_verifier: codeVerifier,
  });
​
  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });
​
  const data = await response.json();
​
  return {
    accessToken: data.access_token,
    idToken: data.id_token,
    refreshToken: data.refresh_token,
    expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
  };
}

One gotcha: Cognito's token endpoint requires application/x-www-form-urlencoded encoding, not JSON. This tripped me up initially.

Step 5: Token Storage and Refresh

Tokens are persisted to device storage using Capacitor Preferences (iOS UserDefaults, Android SharedPreferences). On app launch, we check for stored tokens and restore the session if they're still valid:

useEffect(() => {
  async function restoreSession() {
    const loggedIn = await checkIsLoggedIn();
    if (loggedIn) {
      const userInfo = await getCurrentUser(oauthConfig);
      const token = await getValidAccessToken(oauthConfig);
      if (userInfo && token) {
        setUser(userInfo);
        setIsLoggedIn(true);
        setJwt(token);
        dispatch(setAuth({ jwt: token, email: userInfo.email, fullname: userInfo.name }));
      }
    }
    setIsLoading(false);
  }
  restoreSession();
}, []);

When the access token expires (default 1 hour), we use the refresh token to get a new one without requiring re-authentication:

export async function getValidAccessToken(config: CognitoOAuthConfig): Promise<string | null> {
  const tokens = await loadTokens();
  if (!tokens) return null;
​
  const now = Math.floor(Date.now() / 1000);
  if (tokens.expiresAt - now < 60) {
    const refreshed = await refreshTokens(config);
    return refreshed?.accessToken ?? null;
  }
​
  return tokens.accessToken;
}

The 60-second buffer before expiry prevents edge cases where a token expires between being checked and being used in an API call.

A note on Cognito's refresh behavior: Cognito does NOT return a new refresh token when you refresh — you keep the same refresh token until it expires (default 30 days, configurable up to 10 years). This differs from some OAuth providers that rotate refresh tokens.

Step 6: API Integration

The API client uses a module-level JWT cache with an onSessionExpired callback that triggers token refresh:

const apiClient = createApiClient({
  baseURL: 'https://api.corkhounds.com',
  getJwt: () => currentJwt,
  onSessionExpired: async () => {
    const newToken = await getValidAccessToken(oauthConfig);
    if (newToken) {
      currentJwt = newToken;
    }
    return newToken;
  },
});

When an API call receives a 401 response, the axios interceptor calls onSessionExpired, which refreshes the token and retries the request — all transparent to the calling code.

Logout

Logout has two levels. Local logout clears the device tokens. Full logout also opens Cognito's /logout endpoint to end the server-side session, ensuring the next login requires full re-authentication:

export async function logout(
  config: CognitoOAuthConfig,
  options: { localOnly?: boolean } = {},
): Promise<void> {
  await clearTokens();
​
  if (!options.localOnly) {
    const params = new URLSearchParams({
      client_id: config.clientId,
      redirect_uri: config.logoutRedirectUri,
      response_type: 'code',
    });
​
    await Browser.open({
      url: `https://${config.domain}/logout?${params.toString()}`,
      windowName: '_self',
    });
  }
}

One thing I discovered the hard way: Cognito's logout endpoint requires redirect_uri (not logout_uri as some documentation suggests) and requires response_type: 'code'. Without response_type, Cognito returns an error.

Extracting User Info

Cognito's ID token is a standard JWT containing user profile claims. We decode it client-side to display the user's name and email. We don't verify the signature client-side because we received the token directly from Cognito over HTTPS, and our API server verifies the signature on every request.

One Cognito-specific detail: the name claim isn't always populated. If the user signed up with email/password, Cognito stores the name as separate given_name and family_name claims. Our extraction handles both cases:

export function extractUserInfo(idToken: string): UserInfo {
  const payload = decodeJwtPayload(idToken);
  const name = (payload.name as string | undefined)
    ?? ([payload.given_name, payload.family_name].filter(Boolean).join(' ') || undefined);
​
  return {
    sub: payload.sub as string,
    email: payload.email as string,
    emailVerified: payload.email_verified as boolean,
    name,
    picture: payload.picture as string | undefined,
  };
}

Comparing the Two Approaches

Amplify (2021) Direct PKCE (2026)
Dependencies aws-amplify, amazon-cognito-identity-js, @aws-sdk/client-cognito-identity-provider @capacitor/browser, @capacitor/preferences
Private APIs Auth._oAuthHandler.handleAuthResponse() None
Code complexity Token parsing + CognitoUser construction + session registration Standard OAuth2 POST
Redirect handling Manual — Amplify can't intercept custom URL schemes Capacitor appUrlOpen event
Token refresh Via Amplify internals Direct POST to /oauth2/token
Logout Auth.signOut() didn't clear hosted UI session Direct call to /logout endpoint
Portability Tied to AWS Amplify Works with any OAuth2 provider
Bundle impact ~200KB+ (Amplify Auth module) ~5KB (our code only)

Cognito Setup Checklist

If you're implementing this for your own app, here's what you need to configure in the AWS Console:

  1. Cognito User Pool → App Integration → App Client Settings:
    • Ensure "Authorization Code Grant" is enabled
    • Add com.yourapp.scheme://callback to Allowed Callback URLs
    • Add com.yourapp.scheme://signout to Allowed Sign-out URLs
    • Enable scopes: email, profile, openid
    • Use a public client (no client secret) — PKCE handles the security
  2. iOSInfo.plist:
    • Register your custom URL scheme under CFBundleURLTypes
  3. AndroidAndroidManifest.xml:
    • Add an intent filter for your custom URL scheme
  4. Important: When updating Cognito App Client settings via the CLI (update-user-pool-client), you must include ALL existing parameters — the command is a full replacement, not a patch. Omitting a parameter resets it to its default value.

Conclusion

Dropping Amplify in favor of direct OAuth2 PKCE was one of the best decisions I made during the Cork Hounds mobile app rebuild. The implementation is simpler, more portable, has no private API dependencies, and weighs a fraction of the Amplify bundle. It follows the same standard protocol that Amplify uses internally — we just removed the middleman.

The complete source code for cognito-oauth.ts is extensively documented with JSDoc comments explaining every function, every parameter, and the security considerations behind each decision. If you're building a Capacitor or Cordova app with Cognito, I'd encourage you to try this approach before reaching for Amplify.

Leave a comment if you found this useful or need any assistance!