Migrating a Production React App from AWS Amplify v5 → v6 and AWS SDK v2 → v3
Table of contents
- Why migrate at all
- The stack and the scope
- Version matrix: before and after
- Part I — Configuration: the foundation everything else stands on
- Part II — Auth API migration (signIn, confirmSignIn, signOut, MFA)
- Part III — Credentials and role switching (the SDK v2 → v3 heart of it)
- Part IV — REST API migration (API.get → get/post)
- Part V — Error handling, retries, and cancellation
- Part VI — The production bugs nobody warns you about
- 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/DELETEin 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
| Concern | v5 / SDK v2 (before) | v6 / SDK v3 (after) |
|---|---|---|
| Amplify auth import | import { Auth } from 'aws-amplify' | import { signIn, confirmSignIn, ... } from 'aws-amplify/auth' |
| Sign in | Auth.signIn(email, pw) → user object | signIn({ username, password }) → { isSignedIn, nextStep } |
| MFA challenge marker | user.challengeName | nextStep.signInStep |
| Confirm challenge | Auth.confirmSignIn(user, code, mfaType) | confirmSignIn({ challengeResponse: code }) |
| Session | Auth.currentSession() | fetchAuthSession() → { tokens, credentials, identityId } |
| ID token | session.getIdToken().getJwtToken() | session.tokens.idToken.toString() |
| Credentials | Auth.currentCredentials() | fetchAuthSession() → session.credentials |
| REST | API.get(name, path, init) | get({ apiName, path, options }) from aws-amplify/api |
| Cognito Identity (direct) | new AWS.CognitoIdentity() | new CognitoIdentityClient() + GetCredentialsForIdentityCommand |
| Custom credentials | Object.assign into internal store | official credentialsProvider via Amplify.configure() |
| Config | Amplify.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 oldaws-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_CODECONFIRM_SIGN_IN_WITH_TOTP_CODECONTINUE_SIGN_IN_WITH_TOTP_SETUPCONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIREDRESET_PASSWORDDONE
Two behavior changes here that the rename doesn't telegraph:
-
RESET_PASSWORDis returned as a step, not thrown. In v5 a user in force-reset state threwPasswordResetRequiredException. In v6 you don't get an exception — you getnextStep.signInStep === 'RESET_PASSWORD'. If your code only had acatchfor the old exception, the reset flow silently never triggers. -
DONEwith 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()returnsDONEimmediately. We require MFA for everyone, so we explicitly checkfetchMFAPreference()after aDONEand 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 callscleanActiveSignIn()). - Starting a new
signIn()(resets the store). sessionStorage.clear()mid-flight. This one is brutal because it looks unrelated. The challenge session is stored insessionStorageunderCognitoSignInState.*. We had a "clear storage on the login page" cleanup that, on certain navigations, ran aftersignIn()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 (theDONE-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.sharedSecretcomes 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()callsclearCredentials()on the currently configured credential provider. This matters enormously for role switching (next section): if you've swapped in a custom provider,signOutclears 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
localStorageunderCognitoIdentityServiceProvider.{clientId}.{username}.*. - The in-flight sign-in challenge lives in
sessionStorageunderCognitoSignInState.*.
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:
- Modular client package.
@aws-sdk/client-cognito-identityinstead of the 50MBaws-sdkmonolith. You only bundle the service you use. (Across our app this was the biggest bundle-size win of the migration.) - Command pattern. Instead of
client.someOperation(params).promise(), you build aCommandobject andclient.send(it). Every operation is a class. It's more verbose but it's what makes the middleware stack and tree-shaking work. - 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:
visibilitychangefires → creds are expired →refreshCredentials().fetchAuthSession()sees the ID token is expired too, but the refresh token is valid → Amplify silently uses it → new ID token.GetCredentialsForIdentitywith that fresh token → new STS creds.- All queued API calls (coalesced onto
pendingRefresh) proceed with200.
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:
confirmSignInfails with "Unable to get user session following successful sign-in" — because when you set a customcredentialsProviderfor the role switch, you have to also passtokenProviderin the samelibraryOptions.Authobject. OmittokenProviderand v6 silently drops it, which breaks the challenge state machine. Resetting restores both.- 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 bothtokenProviderandcredentialsProvider, always, together. Setting one and omitting the other nukes the one you omitted.
7. Part IV — REST API migration
7.1 API.get → get
// 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(notedel, becausedeleteis a reserved word). - Each call returns an operation object, not a promise. You await
op.response, and you can callop.cancel(). queryStringParameters→queryParams.- The response body is not auto-parsed. You get a
bodywith.json()/.text()/.blob()methods and you pick. - Auth mode comes from the Amplify config; custom
x-api-keygoes inoptions.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:
makeRequestis 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 letshandleResponsecreate a fresh op for the retry. (See 9.3.)204 No Contentand empty bodies returnnullinstead of throwing onbody.json(). v6'sbody.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.bodyis a JSON string (the raw response body — you have toJSON.parseit yourself).error.response.statusCodeis 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 HTTPstatusCode, 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.jstracks every in-flight operation in aMap.cancelAllRequests()calls.cancel()on all of them;PrivateRouteinvokes it on route change viauseLocation().- Shared/background calls (ACL, logo, sponsor) pass
skipCancel: trueso they survive navigation. - Cancelled requests are tagged in a
WeakSetso 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-applieslibraryOptionsyou 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 bothtokenProviderandcredentialsProvidertogether.
Auth
- Switch from
user.challengeNametonextStep.signInStep. - Handle
RESET_PASSWORDas a returned step, not a thrown exception. - After a
DONEstep, verify MFA is actually configured if you require it (fetchMFAPreference). - Read
sharedSecretfromnextStep.totpSetupDetails; never callsetUpTOTP()mid-challenge. - Call
updateMFAPreference({ totp: 'PREFERRED' })explicitly after TOTP setup. - Use
verifyTOTPSetupfor post-auth setup,confirmSignInfor mid-challenge setup. - Never
sessionStorage.clear()betweensignIn()andconfirmSignIn(). - Reset to default providers after every
signOut()and on login mount.
Credentials / SDK v3
- Replace the
aws-sdkmonolith with modular@aws-sdk/client-*packages. - Replace
client.op(params).promise()withclient.send(new OpCommand(params)). - Replace any
Object.assign-into-internals credential hack with a realcredentialsProvider. - In a custom provider: add a recursion guard, a concurrency-coalescing promise, a proactive timer, and a
visibilitychangerefresh, and single-instance teardown.
REST
-
API.get/post→get/postfromaws-amplify/api; rememberdel. - Await
op.response, thenbody.json()/.text()/.blob()— and guard204/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,
forceRefreshonce (coalesced) and retry once. - Use
forceRefresh: truein exactly one place; cachedfetchAuthSession()everywhere else.
Error handling
- Write a
parseErrorResponseforRestApiError(error.response.bodyis 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
Stay in the loop
New posts land in your inbox. No noise, no ads.