← Back
30 min read🔍 curious📍 Bangalore, 1 PMworkawsamplifyaws-sdkcognitoreactmigrationauthentication

Migrating a Production React App from AWS Amplify v5 → v6 and AWS SDK v2 → v3

This is the long version. Not "here are the 5 functions that changed name," but the full end-to-end story of taking a live, production investment platform off two end-of-life dependencies at once — Amplify v5 and AWS SDK v2 — including every subtle runtime bug that only showed up.

Table of contents

  1. Why migrate at all
  2. The stack and the scope
  3. Version matrix: before and after
  4. Part I — Configuration: the foundation everything else stands on
  5. Part II — Auth API migration (signIn, confirmSignIn, signOut, MFA)
  6. Part III — Credentials and role switching (the SDK v2 → v3 heart of it)
  7. Part IV — REST API migration (API.get → get/post)
  8. Part V — Error handling, retries, and cancellation
  9. Part VI — The production bugs nobody warns you about
  10. Part VII — The migration checklist

1. Why migrate at all

Two reasons, both boring and both non-negotiable:

  • Amplify v5 is end-of-life. Security patches stop, peer-dependency drift sets in, and you eventually can't upgrade React or anything else without fighting it.
  • AWS SDK for JavaScript v2 is end-of-life (maintenance mode ended; v3 is the supported line). v2 ships one giant monolith; v3 is modular, tree-shakeable, and middleware-based.

Doing them together sounds reckless, but they're entangled: Amplify v6 internally uses SDK v3 clients, and our role-switching code talked to Cognito Identity directly via SDK v2. Splitting the migration into two PRs would have meant maintaining a Frankenstein state where half the credential path was v2 and half was v6. We bit the bullet and did both at once.

The platform is Aris Investing — a React SPA with three user groups (portfolio managers, "adv-classic" advisors, and admins), Cognito-backed auth with mandatory TOTP MFA, IAM-signed REST APIs behind API Gateway, and a role-switching feature that lets admins assume different IAM roles at runtime. In other words: every part of the auth and API surface that changed between versions, we used heavily.


2. The stack and the scope

React 18 (Vite — not CRA, so import.meta.env, not process.env)
Redux Toolkit          state
React Router v6         routing
MUI v5                  UI
aws-amplify v6          auth + REST  (aws-amplify/auth, aws-amplify/api)
@aws-sdk/client-cognito-identity v3   direct Cognito Identity calls for role-switch

Surfaces touched by the migration:

  • Auth flows — login, MFA challenge, TOTP setup, new-password-required, password reset, logout, cross-tab logout.
  • REST API layer — every GET/POST/PUT/PATCH/DELETE in the app.
  • Credential handling — identity-pool STS credentials, refresh, expiry.
  • Role switching — assuming a custom IAM role via GetCredentialsForIdentity.
  • Error handling — the entire toast/error-message pipeline, because v6 error shapes are completely different.

That's basically the whole nervous system of the app. Plan accordingly.


3. Version matrix: before and after

Concernv5 / SDK v2 (before)v6 / SDK v3 (after)
Amplify auth importimport { Auth } from 'aws-amplify'import { signIn, confirmSignIn, ... } from 'aws-amplify/auth'
Sign inAuth.signIn(email, pw) → user objectsignIn({ username, password }){ isSignedIn, nextStep }
MFA challenge markeruser.challengeNamenextStep.signInStep
Confirm challengeAuth.confirmSignIn(user, code, mfaType)confirmSignIn({ challengeResponse: code })
SessionAuth.currentSession()fetchAuthSession(){ tokens, credentials, identityId }
ID tokensession.getIdToken().getJwtToken()session.tokens.idToken.toString()
CredentialsAuth.currentCredentials()fetchAuthSession()session.credentials
RESTAPI.get(name, path, init)get({ apiName, path, options }) from aws-amplify/api
Cognito Identity (direct)new AWS.CognitoIdentity()new CognitoIdentityClient() + GetCredentialsForIdentityCommand
Custom credentialsObject.assign into internal storeofficial credentialsProvider via Amplify.configure()
ConfigAmplify.configure(awsconfig)Amplify.configure(resourceConfig, libraryOptions)

The top half of that table is the easy part — a find-and-replace afternoon. The bottom half is where the month went.


4. Part I — Configuration

4.1 Amplify.configure() now takes two arguments

In v5, Amplify.configure(awsconfig) took one blob and you were done. In v6 it takes two arguments:

Amplify.configure(resourceConfig, libraryOptions)
  • resourceConfig — the "what AWS resources" part (user pool, identity pool, API endpoints). This is roughly your old aws-exports.
  • libraryOptions — the "how should the library behave" part: custom token providers, credential providers, REST retry strategy, storage, SSR flags.

The trap that bit us first: Amplify.configure() replaces all libraryOptions on every call. It does not merge. Any place in the app that calls configure() again — and we had several (role switch, auth reset, initial boot) — silently wipes whatever libraryOptions you set last time.

This is so dangerous that we banned the raw call entirely and routed everything through a wrapper.

4.2 The configureAmplify wrapper — always re-merge what you can't afford to lose

// src/utils/configureAmplify.js
import { Amplify } from 'aws-amplify'

export const configureAmplify = (resourceConfig, libraryOptions = {}) => {
  Amplify.configure(resourceConfig, {
    ...libraryOptions,
    API: {
      ...libraryOptions?.API,
      REST: {
        ...libraryOptions?.API?.REST,
        retryStrategy: { strategy: 'no-retry' },
      },
    },
  })
}

Why the retryStrategy: { strategy: 'no-retry' } is hard-coded here is a whole section on its own (see Part V). The short version: in v6 every REST call has a built-in 3-attempt retry with jittered backoff, on by default. In v5 only specific upload/download calls retried. If you don't disable it centrally, you get phantom duplicate API calls all over the app, and any place that reconfigures Amplify drops the setting again. So the wrapper is the only sanctioned way to configure Amplify, and it re-applies no-retry on every single call.

Rule we adopted: nobody calls Amplify.configure() directly. Ever. Lint for it.

4.3 Vite, not CRA

A small but real one: this app is on Vite, so environment variables are import.meta.env.REACT_APP_*, not process.env.REACT_APP_*. If you copy snippets from the Amplify docs (which assume CRA/process.env), they'll silently read undefined. Every env access in the migrated code looks like:

const loginsKey =
  `cognito-idp.${import.meta.env.REACT_APP_REGION}.amazonaws.com/${import.meta.env.REACT_APP_USER_POOL_ID}`

5. Part II — Auth API migration

5.1 signIn: from "a user object" to "a state machine step"

v5 gave you back a CognitoUser and you inspected user.challengeName to know what to do next. v6 returns a flat result describing the next step in a state machine:

// v6
import { signIn } from 'aws-amplify/auth'

const { isSignedIn, nextStep } = await signIn({ username: email, password })
// nextStep.signInStep is the new "challengeName"

nextStep.signInStep can be:

  • CONFIRM_SIGN_IN_WITH_SMS_CODE
  • CONFIRM_SIGN_IN_WITH_TOTP_CODE
  • CONTINUE_SIGN_IN_WITH_TOTP_SETUP
  • CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED
  • RESET_PASSWORD
  • DONE

Two behavior changes here that the rename doesn't telegraph:

  1. RESET_PASSWORD is returned as a step, not thrown. In v5 a user in force-reset state threw PasswordResetRequiredException. In v6 you don't get an exception — you get nextStep.signInStep === 'RESET_PASSWORD'. If your code only had a catch for the old exception, the reset flow silently never triggers.

  2. DONE with no MFA is a security hole if you trust it blindly. When Cognito MFA is configured as optional and a user has no MFA set up, signIn() returns DONE immediately. We require MFA for everyone, so we explicitly check fetchMFAPreference() after a DONE and force TOTP setup if nothing is configured, rather than letting the user in.

5.2 confirmSignIn: one object, and a landmine called UnexpectedSignInInterruptionException

// v5
await Auth.confirmSignIn(user, code, 'SOFTWARE_TOKEN_MFA')

// v6
await confirmSignIn({ challengeResponse: code })

Notice v6 takes no user object. That's because v6 keeps the in-flight sign-in challenge in a module-level singleton (signInStore.signInSession, living on a TokenOrchestrator inside cognitoUserPoolsTokenProvider). confirmSignIn reads that singleton to know which challenge it's answering.

This design causes the single most painful error in the whole migration:

UnexpectedSignInInterruptionException — "Unable to get user session following successful sign-in."

It's thrown whenever signInStore.signInSession is null at the moment you call confirmSignIn. Things that null it out:

  • Calling signOut() (it internally calls cleanActiveSignIn()).
  • Starting a new signIn() (resets the store).
  • sessionStorage.clear() mid-flight. This one is brutal because it looks unrelated. The challenge session is stored in sessionStorage under CognitoSignInState.*. We had a "clear storage on the login page" cleanup that, on certain navigations, ran after signIn() had stashed the challenge — wiping it out before the user typed their code.

Rule: never call sessionStorage.clear() while a sign-in challenge is in flight. Clear before signIn(), never between signIn() and confirmSignIn().

Here's our real SignInMfa wrapper, which routes every challenge type through confirmSignIn — note how much v6-specific knowledge is baked in:

// src/pages/mfa/components/SignInMfa.js
import { confirmSignIn, verifyTOTPSetup, updateMFAPreference } from 'aws-amplify/auth'

export const SignInMfa = async ({ user, action, value }) => {
  const step = user?.nextStep?.signInStep

  if ((step === 'CONFIRM_SIGN_IN_WITH_SMS_CODE' ||
       step === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE') && action === 'MFA_LOGIN') {
    const loggedUser = await confirmSignIn({ challengeResponse: value })
    return { type: 'SUCCESS', value: loggedUser }
  }

  if (step === 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED' && action === 'NEW_PASSWORD') {
    const newUser = await confirmSignIn({ challengeResponse: value })
    return { type: 'SUCCESS', value: newUser }
  }

  // TOTP setup branch (see 5.3) ...
}

5.3 TOTP setup: setUpTOTP() will betray you mid-sign-in

In v5 you'd call Auth.setupTOTP(user) to get a shared secret during the challenge. In v6, calling setUpTOTP() during a sign-in challenge throws the same "Unable to get user session following successful sign-in" error — because there's no fully-authenticated session yet.

The fix: the shared secret is already handed to you in the signIn/confirmSignIn response under nextStep.totpSetupDetails.sharedSecret. Read it from there; don't call setUpTOTP().

// inside SignInMfa, TOTP setup branch
if ((step === 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP' || step === undefined) &&
    action === 'MFA_TOTP_SETUP') {

  if (!value) {
    // Just rendering the QR code: pull the secret from the challenge response,
    // do NOT call setUpTOTP() here.
    const sharedSecret = user?.nextStep?.totpSetupDetails?.sharedSecret
    if (sharedSecret) return { type: 'SUCCESS', value: sharedSecret }
    return { type: 'FAIL',
      value: new Error('TOTP setup details unavailable. Please sign in again.') }
  }

  // User typed their first TOTP code:
  if (user?.isPostAuthSetup) {
    // Already authenticated (signed in DONE with no MFA) → use verifyTOTPSetup,
    // because confirmSignIn would throw mid-session.
    await verifyTOTPSetup({ code: value })
    await updateMFAPreference({ totp: 'PREFERRED' })
    return { type: 'SUCCESS', value: { isSignedIn: true, nextStep: { signInStep: 'DONE' } } }
  }

  const loggedUser = await confirmSignIn({ challengeResponse: value })
  // v6 does NOT auto-set the MFA preference after the setup challenge — be explicit.
  await updateMFAPreference({ totp: 'PREFERRED' })
  return { type: 'SUCCESS', value: loggedUser }
}

Three v6-isms in that one branch:

  • Mid-sign-in vs. post-auth TOTP setup are different APIs. During a challenge → confirmSignIn. Already signed in (the DONE-with-no-MFA case) → verifyTOTPSetup. Using the wrong one throws.
  • updateMFAPreference({ totp: 'PREFERRED' }) is mandatory. v5 set the preference implicitly when you completed TOTP setup; v6 completes the challenge but leaves the preference unset. Forget this and the user finishes setup but isn't actually prompted for TOTP next login.
  • sharedSecret comes from the response, never from a fresh API call.

5.4 signOut: local by default, and it touches your custom credential provider

// v6
import { signOut } from 'aws-amplify/auth'
await signOut()                 // local signout, no Cognito network call
await signOut({ global: true }) // revokes tokens server-side

Two things worth knowing:

  • Default is local signout — no network round-trip — so it's safe to call even with expired tokens. Good for "log me out, I'm stuck" buttons.
  • signOut() calls clearCredentials() on the currently configured credential provider. This matters enormously for role switching (next section): if you've swapped in a custom provider, signOut clears that one. You still have to reset back to the default provider afterward, or the next login inherits the stale custom provider.

5.5 fetchAuthSession: the new center of gravity

Auth.currentSession() and Auth.currentCredentials() both collapse into one call:

const session = await fetchAuthSession()
session.tokens?.idToken?.toString()   // JWT
session.credentials                   // { accessKeyId, secretAccessKey, sessionToken, expiration }
session.identityId                    // identity-pool ID

Critical detail: fetchAuthSession() is the cached variant by default — in-memory, no Cognito network call unless the credentials are actually expired (and Amplify single-flights concurrent expired-callers). To force a refresh you pass fetchAuthSession({ forceRefresh: true }). We use forceRefresh in exactly one place in the entire app — the 403 recovery path in apiConfig.js. Everywhere else uses the cheap cached variant. Sprinkling forceRefresh: true around "to be safe" is how you DDoS your own identity pool.

Also: during an active sign-in challenge (before MFA completes), session.tokens is null. The credentials provider then falls back to the unauthenticated identity-pool role if you have one configured. If your prefetch logic reads idToken during the MFA screen, guard for null.

5.6 Token storage moved

  • v5 SDK kept everything in its own format.
  • v6 stores tokens in localStorage under CognitoIdentityServiceProvider.{clientId}.{username}.*.
  • The in-flight sign-in challenge lives in sessionStorage under CognitoSignInState.*.

If you have any "nuke storage" housekeeping, it now has the power to break auth in ways it didn't in v5. Audit every localStorage.clear() / sessionStorage.clear() in your codebase before you ship.


6. Part III — Credentials and role switching

This is where Amplify v6 and AWS SDK v3 meet, and it's the hardest part of the whole migration. Buckle up.

6.1 The v5 hack that v6 kills

Our app lets an admin switch roles at runtime — assume a different IAM role and get a fresh set of STS credentials scoped to it. In v5 this was done with a hack that's almost embarrassing to write down:

// v5 — DO NOT do this in v6
Object.assign(Auth._config.credentials, switchedRoleCredentials)

You reached into Amplify's internal credentials store and overwrote it. It worked because v5's internals were mutable and unguarded. v6 removed the internal store in favor of an official credentialsProvider contract. The Object.assign hack silently no-ops in v6 — your role switch "succeeds" but every subsequent API call signs with the original role's credentials.

6.2 The v6 way: a custom credentialsProvider

v6 lets you inject a credentials provider through libraryOptions.Auth.credentialsProvider. The contract is two methods:

{
  getCredentialsAndIdentityId: async (params) => ({ credentials, identityId }),
  clearCredentialsAndIdentityId: () => void,
}

Amplify calls getCredentialsAndIdentityId() whenever it needs to sign a request. So role switching becomes: build a provider that fetches switched-role credentials via the SDK v3 GetCredentialsForIdentityCommand, hand it to configureAmplify, and let Amplify pull from it.

6.3 SDK v2 → v3 inside the provider

The direct Cognito Identity call is pure SDK v2 → v3 migration, and it's the canonical example of the v3 pattern:

// v2
import AWS from 'aws-sdk'
const ci = new AWS.CognitoIdentity({ region })
const data = await ci.getCredentialsForIdentity(params).promise()

// v3
import { CognitoIdentityClient, GetCredentialsForIdentityCommand }
  from '@aws-sdk/client-cognito-identity'
const cognitoIdentity = new CognitoIdentityClient({ region })
const data = await cognitoIdentity.send(new GetCredentialsForIdentityCommand(params))

The three v3 hallmarks, all visible above:

  1. Modular client package. @aws-sdk/client-cognito-identity instead of the 50MB aws-sdk monolith. You only bundle the service you use. (Across our app this was the biggest bundle-size win of the migration.)
  2. Command pattern. Instead of client.someOperation(params).promise(), you build a Command object and client.send(it). Every operation is a class. It's more verbose but it's what makes the middleware stack and tree-shaking work.
  3. Native promises. No more .promise()send() already returns one.

6.4 The full refreshable provider

Switched-role STS credentials expire after ~1 hour. The default provider can't refresh custom-role credentials for you, so the provider has to own the entire lifecycle: proactive refresh timer, tab-visibility handling, concurrency coalescing, and teardown. Here's the real thing, annotated:

// src/utils/createRefreshableCredentialsProvider.js
import { CognitoIdentityClient, GetCredentialsForIdentityCommand }
  from '@aws-sdk/client-cognito-identity'
import { fetchAuthSession } from 'aws-amplify/auth'

const loginsKey =
  `cognito-idp.${import.meta.env.REACT_APP_REGION}.amazonaws.com/${import.meta.env.REACT_APP_USER_POOL_ID}`

const cognitoIdentity = new CognitoIdentityClient({
  region: import.meta.env.REACT_APP_IDENTITY_POOL_REGION,
})

const EXPIRY_BUFFER_MS = 5 * 60 * 1000 // refresh 5 min early

// configureAmplify() swaps the provider WITHOUT disposing the old one, so we
// dispose it here to avoid leaking its timer + visibility listener (which would
// each fire their own Cognito call → duplicate GetCredentialsForIdentity).
let activeProviderCleanup = null

export const createRefreshableCredentialsProvider = (
  { roleArn, initialCredentials, identityId, jwtTokenFallback }
) => {
  let cachedCredentials = initialCredentials
  let cachedIdentityId = identityId
  let isRefreshing = false   // recursion guard: fetchAuthSession() re-enters getCredentials...
  let pendingRefresh = null  // shared promise so concurrent callers share ONE Cognito call
  let refreshTimer = null

  const isExpired = (c) => {
    if (!c?.expiration) return true
    return Date.now() >= new Date(c.expiration).getTime() - EXPIRY_BUFFER_MS
  }

  const scheduleProactiveRefresh = () => {
    if (!cachedCredentials?.expiration) return
    clearTimeout(refreshTimer)
    const delay = new Date(cachedCredentials.expiration).getTime() - EXPIRY_BUFFER_MS - Date.now()
    if (delay <= 0) return
    refreshTimer = setTimeout(() => {
      if (!pendingRefresh) {
        pendingRefresh = refreshCredentials()
        pendingRefresh.catch(() => {}) // swallow timed-out-session so Sentry doesn't alert
      }
    }, delay)
  }

  const refreshCredentials = async () => {
    isRefreshing = true
    try {
      const session = await fetchAuthSession() // cached; isRefreshing blocks re-entry
      const tokenID = session.tokens?.idToken?.toString() ?? jwtTokenFallback
      if (!tokenID) throw new Error('No ID token available to refresh switched-role creds')

      const data = await cognitoIdentity.send(new GetCredentialsForIdentityCommand({
        IdentityId: session?.identityId || cachedIdentityId,
        CustomRoleArn: roleArn,                 // <-- the switched role
        Logins: { [loginsKey]: tokenID },
      }))

      cachedCredentials = {
        accessKeyId: data?.Credentials?.AccessKeyId,
        secretAccessKey: data?.Credentials?.SecretKey,
        sessionToken: data?.Credentials?.SessionToken,
        expiration: data?.Credentials?.Expiration,
      }
      cachedIdentityId = data?.IdentityId
      scheduleProactiveRefresh()
      return { credentials: cachedCredentials, identityId: cachedIdentityId }
    } finally {
      isRefreshing = false
      pendingRefresh = null
    }
  }

  // Browsers throttle setTimeout in hidden tabs; on re-show, refresh now if expired.
  const handleVisibilityChange = () => {
    if (document.visibilityState === 'visible' && isExpired(cachedCredentials)) {
      if (!pendingRefresh) {
        pendingRefresh = refreshCredentials()
        pendingRefresh.catch(() => {})
      }
    }
  }

  if (activeProviderCleanup) activeProviderCleanup() // dispose the previous instance first
  const cleanup = () => {
    clearTimeout(refreshTimer)
    document.removeEventListener('visibilitychange', handleVisibilityChange)
  }
  activeProviderCleanup = cleanup
  document.addEventListener('visibilitychange', handleVisibilityChange)
  scheduleProactiveRefresh()

  return {
    getCredentialsAndIdentityId: async () => {
      if (isRefreshing) {
        // Break the fetchAuthSession → getCredentials loop by returning stale creds.
        return { credentials: cachedCredentials, identityId: cachedIdentityId }
      }
      if (isExpired(cachedCredentials)) {
        if (!pendingRefresh) pendingRefresh = refreshCredentials() // coalesce the burst
        return pendingRefresh
      }
      return { credentials: cachedCredentials, identityId: cachedIdentityId }
    },
    clearCredentialsAndIdentityId: () => {
      cleanup()
      cachedCredentials = null
      cachedIdentityId = null
      if (activeProviderCleanup === cleanup) activeProviderCleanup = null
    },
  }
}

Every line in there is a scar. Let me explain the four hardest-won ones.

(a) The recursion guard (isRefreshing). refreshCredentials() calls fetchAuthSession() to get the ID token. But fetchAuthSession() can itself ask the credentials provider for credentials → which calls getCredentialsAndIdentityId → which, if expired, calls refreshCredentials() → infinite loop. The isRefreshing flag makes getCredentialsAndIdentityId return the stale creds during a refresh, breaking the cycle. The token fetch only needs the token provider, not fresh creds, so stale is fine for that one beat.

(b) Concurrency coalescing (pendingRefresh). On a cold tab wake, six API calls fire at once. Each sees expired credentials. Without coalescing, each launches its own GetCredentialsForIdentity — six identical Cognito calls, sometimes racing into throttling. The shared pendingRefresh promise means the first caller starts the refresh and the other five await the same promise. This was a real production bug: "6 concurrent Cognito calls after idle tab."

(c) The proactive timer and the visibility listener. A setTimeout scheduled for "5 minutes before expiry" works great in a visible tab. But browsers throttle timers in hidden tabs, so a tab left in the background for an hour wakes up with dead credentials and a timer that never fired. The visibilitychange handler covers that: on re-show, if creds are expired, refresh immediately. You need both — the timer for visible idle tabs, the listener for hidden ones.

(d) Single-instance teardown (activeProviderCleanup). Amplify.configure() (via our wrapper) replaces the provider but never tells the old instance to stop. The old instance keeps its timer and its visibility listener alive. Switch roles three times and you've got three zombie providers, each firing its own Cognito refresh on every tab focus — N duplicate calls. The module-level activeProviderCleanup disposes the previous instance before wiring up the new one, guaranteeing exactly one live provider.

6.5 The 2-hour idle scenario, fully handled

The acid test for the whole credential design:

STS creds      → expire after  1h
Cognito ID tok → expires after  1h
refresh token  → valid for     30d

User leaves the tab idle for 2 hours, comes back:

  1. visibilitychange fires → creds are expired → refreshCredentials().
  2. fetchAuthSession() sees the ID token is expired too, but the refresh token is valid → Amplify silently uses it → new ID token.
  3. GetCredentialsForIdentity with that fresh token → new STS creds.
  4. All queued API calls (coalesced onto pendingRefresh) proceed with 200.

The only way this fails is 30+ days idle (refresh token finally expired) — at which point the app correctly detects an unauthorized error and redirects to login.

6.6 Resetting to default — the most-forgotten step

After any signOut(), and on the login page mount, you must put the default providers back:

// src/utils/resetAuthGroupToDefault.js
import { cognitoUserPoolsTokenProvider, cognitoCredentialsProvider }
  from 'aws-amplify/auth/cognito'
import { defaultStorage } from 'aws-amplify/utils'
import { awsconfig } from '../aws-exports'
import { configureAmplify } from './configureAmplify'

export const resetAuthGroupToDefault = () => {
  cognitoUserPoolsTokenProvider.setKeyValueStorage(defaultStorage)
  configureAmplify(awsconfig, {
    Auth: {
      tokenProvider: cognitoUserPoolsTokenProvider,
      credentialsProvider: cognitoCredentialsProvider,
    },
  })
}

If you skip this, two things break on the next login:

  1. confirmSignIn fails with "Unable to get user session following successful sign-in" — because when you set a custom credentialsProvider for the role switch, you have to also pass tokenProvider in the same libraryOptions.Auth object. Omit tokenProvider and v6 silently drops it, which breaks the challenge state machine. Resetting restores both.
  2. API calls return 403 — Amplify is still signing with the stale switched-role credentials.

The single most important v6 config rule: when you customize libraryOptions.Auth, you pass both tokenProvider and credentialsProvider, always, together. Setting one and omitting the other nukes the one you omitted.


7. Part IV — REST API migration

7.1 API.getget

// v5
import { API } from 'aws-amplify'
const data = await API.get('myApi', '/path', { headers, queryStringParameters })

// v6
import { get } from 'aws-amplify/api'
const op = get({ apiName: 'myApi', path: '/path', options: { headers, queryParams } })
const { body, statusCode } = await op.response
const data = await body.json()

Differences that matter:

  • Named exports get/post/put/patch/del (note del, because delete is a reserved word).
  • Each call returns an operation object, not a promise. You await op.response, and you can call op.cancel().
  • queryStringParametersqueryParams.
  • The response body is not auto-parsed. You get a body with .json() / .text() / .blob() methods and you pick.
  • Auth mode comes from the Amplify config; custom x-api-key goes in options.headers.

7.2 We wrapped all of it

Rather than scatter op.response / body.json() across hundreds of call sites, we built a thin service layer that preserves the old ergonomics (getAPI(name, path, query) returns parsed data) while centralizing the v6 mechanics, the x-api-key injection, cancellation tracking, and 403 recovery. Here's the shape:

// src/services/apiConfig.js (abridged)
import { del, get, patch, post, put } from 'aws-amplify/api'
import { API_KEY_MAP } from './apiKeyMap'

const getHeaders = (apiName) => ({ 'x-api-key': API_KEY_MAP[apiName] })

const readResponseBody = async (request, responseType) => {
  const { body, statusCode } = await request.response
  if (!body || statusCode === 204) return null
  if (responseType === 'blob' && body.blob) return body.blob()
  if (responseType === 'text' && body.text) return body.text()
  try { return await body.json() }
  catch { return body.text?.() ?? null }
}

export const getAPI = async (apiName, path, queryStringParameters = {}, options = {}) => {
  const { responseType, skipCancel } = extractOptions(options)
  const makeRequest = () => get({
    apiName,
    path,
    options: {
      headers: getHeaders(apiName),
      ...(Object.keys(queryStringParameters).length && { queryParams: queryStringParameters }),
    },
  })
  return handleResponse(makeRequest, responseType, {
    skipCancel, meta: { apiName, path, method: 'GET' },
  })
}
// postAPI/putAPI/patchAPI/deleteAPI follow the same pattern.

Two design choices that paid off:

  • makeRequest is a factory, not a built operation. Because the 403-retry path needs to rebuild the operation (you can't re-await a settled one), passing a factory lets handleResponse create a fresh op for the retry. (See 9.3.)
  • 204 No Content and empty bodies return null instead of throwing on body.json(). v6's body.json() throws on an empty body, which v5's auto-parse quietly tolerated. This silently broke every "delete returns nothing" endpoint until we guarded it.

8. Part V — Error handling, retries, and cancellation

8.1 Error shapes are completely different

v5 error handling assumed a certain object shape. v6 throws RestApiError, where:

  • error.response.body is a JSON string (the raw response body — you have to JSON.parse it yourself).
  • error.response.statusCode is the HTTP status.

So we wrote a parser used everywhere a toast might fire:

// src/utils/parseErrorResponse.js
export const parseErrorResponse = (error) => {
  if (error?.response?.body && typeof error.response.body === 'string') {
    try {
      const parsed = JSON.parse(error.response.body)
      return {
        data: parsed,
        statusCode: error.response.statusCode,
        userMessage: parsed?.message || parsed?.errorInfo?.userMessage,
      }
    } catch {
      return { data: null, statusCode: error.response.statusCode, userMessage: null }
    }
  }
  return { data: null, statusCode: null, userMessage: null }
}

The message resolution order is: backend's response.message first, then errorInfo.userMessage, then a generic fallback in the calling hook.

8.2 The string-vs-object footgun in the toast hook

Our useErrorToast hook's showError accepts either a string or an error object, and they behave differently:

  • String first arg → shows the toast unconditionally.
  • Object first arg → runs parseErrorResponse, then a guard suppresses the toast when there's no HTTP statusCode, or when the status is >= 500.

That >= 500 suppression is intentional — it stops a flood of identical "DB failed to load" toasts when a backend has a bad minute. But the !statusCode part is a footgun: Cognito auth errors (NotAuthorizedException, etc.) have no response.statusCode. So calling showError(cognitoError) object-style silently swallows the wrong-password toast, while showError('Wrong password') string-style works.

This is exactly the kind of bug that passes locally and fails in one environment: our staging Login.jsx passed the error object and showed no wrong-password toast; local passed strings and worked. The clean fix only gates on suppression when a statusCode actually exists:

if (!sessionExpiredToast && !overrideStatusCheck && statusCode) {
  if (statusCode < 400 || statusCode >= 500) return // keep the 5xx spam guard
}

Lesson: pick one calling convention for your error helper and enforce it. Dual string|object signatures will bite you across environments.

8.3 The phantom-retry problem

As mentioned in Part I: v6's REST client retries 3× with jittered backoff by default. In v5 only specific upload/download fetches retried. So after the naive migration, half the app was firing every failed (and some "slow") request three times. Symptoms: duplicate writes, tripled error logs, doubled load on flaky endpoints.

The fix is the centralized retryStrategy: { strategy: 'no-retry' } in the configureAmplify wrapper, re-applied on every configure. The handful of endpoints that genuinely needed retry (the old upload/download fetch calls) got an explicit fetchWithRetry() utility instead — opt-in, not opt-out.

8.4 Cancellation on route change — global, with a kill switch

The team wanted in-flight requests cancelled on navigation without sprinkling AbortController into every component's useEffect. v6's per-operation .cancel() made a global approach possible:

  • apiConfig.js tracks every in-flight operation in a Map.
  • cancelAllRequests() calls .cancel() on all of them; PrivateRoute invokes it on route change via useLocation().
  • Shared/background calls (ACL, logo, sponsor) pass skipCancel: true so they survive navigation.
  • Cancelled requests are tagged in a WeakSet so the error handler can tell a deliberate cancellation from a real failure and suppress the error toast for it.

We shipped it behind a kill switch (cancelOnRouteChangeEnabled = false) so cancellation is globally disabled until every background API is audited and tagged skipCancel. A manager's reasonable concern — "what if we cancel a background call that mattered?" — became a one-line flag instead of a revert.

export let cancelOnRouteChangeEnabled = false
export const cancelAllRequests = () => {
  if (!cancelOnRouteChangeEnabled) return // no-op until APIs are audited
  // ...iterate inflightRequests, tag in WeakSet, call .cancel()
}

9. Part VI — The production bugs nobody warns you about

These are the issues that didn't show up in dev and didn't show up in the migration guide. They showed up in production, under real timing.

9.1 Every REST call is SigV4-signed — even with x-api-key

We verified this against the installed @aws-amplify/api-rest source, not the docs: every REST call internally calls fetchAuthSession() and is SigV4-signed with identity-pool STS credentials, and sends your x-api-key. For REST (unlike GraphQL), the presence of x-api-key does not disable IAM signing. So every call is doubly authenticated.

Why you care: this means every REST call depends on having valid credentials. If credentials are stale, it's not just your IAM-auth endpoints that 403 — it's everything. Which leads directly to:

9.2 The cold-start credential burst

Amplify v6's credentialsProvider has no concurrency lock. On first load, the app fires a burst of API calls; each independently triggers credential resolution; each fires its own GetId + GetCredentialsForIdentity. We saw N×2 identity calls on cold start.

The fix is a coalescer in the API layer — resolve the session once and let the burst share it:

let sessionPrimePromise = null
const primeSessionOnce = () => {
  if (!sessionPrimePromise) {
    sessionPrimePromise = fetchAuthSession()
      .catch(() => null)
      .finally(() => { sessionPrimePromise = null })
  }
  return sessionPrimePromise
}

handleResponse awaits primeSessionOnce() before firing the actual request, so the whole first burst rides one credential resolution instead of stampeding.

9.3 The idle-tab 403

Tab sits idle for an hour. STS creds and the ID token both expire (1h TTL each). The user comes back and the app fires a burst. The cached fetchAuthSession() hasn't refreshed yet, so the first calls sign with dead creds and get 403. This affects all users, not just role-switched ones (the default provider has the same gap).

The universal, provider-agnostic fix lives in apiConfig.js: on a 403, force one fresh session (coalesced) and retry the request exactly once.

const isForbidden = (e) => e?.response?.statusCode === 403

let authRefreshPromise = null
const refreshAuthSessionOnce = () => {
  if (!authRefreshPromise) {
    authRefreshPromise = fetchAuthSession({ forceRefresh: true }) // the ONE forceRefresh in the app
      .catch(() => null)
      .finally(() => { authRefreshPromise = null })
  }
  return authRefreshPromise
}

// inside handleResponse's catch:
if (isForbidden(error)) {
  inflightRequests.delete(request)
  await refreshAuthSessionOnce()   // coalesced: the whole 403 burst shares one refresh
  request = makeRequest()          // rebuild the op (can't re-await a settled one)
  return await readResponseBody(request, responseType) // retry once
}

That's why makeRequest is a factory (7.2): the retry needs a fresh operation. The coalescing means a burst of ten 403s triggers one forceRefresh, not ten.

There's also a complementary proactive optimization on visibilitychange: when the tab is re-shown after being hidden, do a cheap cached fetchAuthSession() to read credentials.expiration, and only forceRefresh if we're within 5 minutes of expiry. This avoids the silent one-retry in the common case — but it's an optimization; the reactive 403-retry is the actual correctness guarantee.

const credsNearExpiry = async () => {
  const { credentials } = await fetchAuthSession() // cached, no network unless expired
  if (!credentials?.expiration) return false
  return Date.now() >= new Date(credentials.expiration).getTime() - EXPIRY_BUFFER_MS
}

9.4 CognitoSignInState.username is the sub, not the email

A subtle one that affects prefetch logic: during the challenge, v6 stores the sign-in state under a key whose username segment is the Cognito sub (UUID), not the email the user typed. If you were keying prefetch caches on "username = email," you'll mismatch. (This also has implications if you later add federated/SSO login, where you can't read the sub from the challenge state at all and must derive it via getCurrentUser after redirect.)

9.5 localStorage quota can silently kill a downstream fetch

Not strictly a v5→v6 change, but the migration's storage relocation surfaced it: one PM user had a 5.7 MB ACL blob. Writing it to localStorage threw QuotaExceededError, which an over-broad catch swallowed — and the failure manifested as a cryptic downstream error with no stack. The fix was wrapping each setItem in its own try/catch and surfacing the quota failure explicitly. Moral: when you move storage around in a migration, audit the size of what you store, not just the keys.

9.6 signOut clears the wrong provider if you didn't reset

Covered in 6.6 but it bears repeating as a "production bug" because that's how it presented: a user role-switched, logged out, logged back in as someone else — and got 403s on everything, because login #2 inherited login #1's switched-role credential provider. The fix (resetAuthGroupToDefault after every signOut and on login mount) is one line, but the symptom looked like a backend permissions bug for a day.


10. Part VII — The migration checklist

If you do nothing else, do these. Ordered roughly by how much pain skipping them causes.

Configuration

  • Replace every raw Amplify.configure() with a wrapper that re-applies libraryOptions you can't afford to lose (token provider, credential provider, retryStrategy: no-retry).
  • Set API.REST.retryStrategy = { strategy: 'no-retry' } globally; opt specific endpoints into retry, don't leave the default on.
  • When customizing libraryOptions.Auth, always pass both tokenProvider and credentialsProvider together.

Auth

  • Switch from user.challengeName to nextStep.signInStep.
  • Handle RESET_PASSWORD as a returned step, not a thrown exception.
  • After a DONE step, verify MFA is actually configured if you require it (fetchMFAPreference).
  • Read sharedSecret from nextStep.totpSetupDetails; never call setUpTOTP() mid-challenge.
  • Call updateMFAPreference({ totp: 'PREFERRED' }) explicitly after TOTP setup.
  • Use verifyTOTPSetup for post-auth setup, confirmSignIn for mid-challenge setup.
  • Never sessionStorage.clear() between signIn() and confirmSignIn().
  • Reset to default providers after every signOut() and on login mount.

Credentials / SDK v3

  • Replace the aws-sdk monolith with modular @aws-sdk/client-* packages.
  • Replace client.op(params).promise() with client.send(new OpCommand(params)).
  • Replace any Object.assign-into-internals credential hack with a real credentialsProvider.
  • In a custom provider: add a recursion guard, a concurrency-coalescing promise, a proactive timer, and a visibilitychange refresh, and single-instance teardown.

REST

  • API.get/postget/post from aws-amplify/api; remember del.
  • Await op.response, then body.json()/.text()/.blob() — and guard 204/empty bodies.
  • Wrap calls in a service layer that builds the op via a factory (so 403-retry can rebuild it).

Resilience

  • Coalesce cold-start credential resolution (primeSessionOnce).
  • On 403, forceRefresh once (coalesced) and retry once.
  • Use forceRefresh: true in exactly one place; cached fetchAuthSession() everywhere else.

Error handling

  • Write a parseErrorResponse for RestApiError (error.response.body is a JSON string).
  • Pick one calling convention for your error/toast helper; don't mix string and object args.
  • Suppress the error toast for deliberately-cancelled requests.

Closing thoughts

The headline migration — renaming Auth.signIn to signIn, API.get to get, aws-sdk to @aws-sdk/client-* — is maybe 20% of the work and you can do it in a day with find-and-replace. The other 80% is the runtime behavior changes that have no compile error to catch them:

  • The retry that's now on by default.
  • The challenge state machine that lives in a module singleton and gets wiped by unrelated storage clears.
  • The credential provider that has no concurrency lock and no auto-refresh for custom roles.
  • The error objects whose body is a string you have to parse yourself.
  • The toast that silently swallows auth errors because they have no HTTP status.

None of those throw at build time. They show up an hour after a user goes idle, or only in staging, or only for the one user with a 5 MB ACL. Budget your time accordingly: the migration isn't done when it compiles, it's done when it survives a 2-hour idle tab, a role switch, a cold start, and a wrong password — in production.

We shipped it. It's live. And now you have the map.

Last updated June 5, 2026

React:One reaction per device
✉️

Stay in the loop

New posts land in your inbox. No noise, no ads.