Skip to main content
New tool CRON Expression Builder — preview next run times before you schedule Apex. Open the builder →
Diagram of OAuth user-agent flow retirement and migration to the Web Server flow with PKCE
Integration

OAuth User-Agent Flow Retirement: Move to Web Server + PKCE

On February 20, 2027 Salesforce stops issuing tokens through the user-agent and hybrid user-agent flows, which SPAs, browser extensions and older hybrid mobile apps still use. This guide shows how to find those clients in code and Login History and move each one to the Web Server flow with PKCE.

The short answer

Salesforce enforces the OAuth user-agent flow retirement on February 20, 2027, so clients that request response_type=token stop getting new tokens. Find them in code and Login History, then move each one to the Web Server or hybrid web-server flow with PKCE, ideally with the code exchange running on a backend.

Key takeaways Grep your repositories for response_type=token, #access_token and location.hash, then query LoginHistory for the three user-agent LoginSubType values over the last 180 days. Switch each client to response_type=code with an S256 code_challenge, and send the matching code_verifier on the token request. Run the code exchange on a small backend so the refresh token and client secret never reach the browser. Turn on the PKCE requirement on each app and test login, refresh and logout in a sandbox with the release update enabled in December. Ship production cutovers in January and make no auth changes in the week of Saturday, February 20, 2027.

On February 20, 2027, Salesforce enforces the retirement of the OAuth user-agent flow and the hybrid user-agent flow. After that date, a client that calls the authorize endpoint with response_type=token and reads the access token out of the URL fragment stops getting new tokens. The replacement is the Web Server (authorization code) flow with PKCE, or the hybrid web-server flow with PKCE for mobile. You have about fourteen weeks to find every such client and move it.

The release update first appeared in late Summer '26. Confirm the date in Setup > Release Updates in your own org before you plan around it, since Salesforce has already moved the date of the sibling username-password retirement once.

Which clients the user-agent flow retirement affects

The client sends the user to /services/oauth2/authorize?response_type=token&client_id=...&redirect_uri=..., and Salesforce returns the browser to something like https://app.example.com/callback#access_token=00D...&instance_url=.... JavaScript on the callback page reads window.location.hash, stores the token and calls the REST API. Nothing ever hits the token endpoint, and the client holds no secret.

This was the standard advice for browser clients for years, so it turns up in places nobody files under integrations:

  • single-page apps in React, Angular or Vue that call the Salesforce REST API directly
  • browser extensions and bookmarklets that admins built for data fixes
  • Electron and other desktop tools that open a browser window to log in
  • older Mobile SDK hybrid apps, which use the hybrid user-agent variant
  • internal dashboards built on an OAuth library configured for the implicit grant

Start in source control. A grep across the repositories your team owns finds most of them in minutes:

grep -rnE 'response_type=token|#access_token|location\.hash|oauth2/authorize' \
  --include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' \
  --include='*.vue' --include='*.html' --include='*.page' .

Open every hit on oauth2/authorize, even where response_type=token is not on the same line. The parameter is often assembled from a config object a few lines away.

Finding them in the org

Code search covers only the code you own. Login History covers everything that has actually logged in, including vendor tools and browser extensions nobody registered with IT. Logins through these flows carry a LoginSubType of OauthUserAgent, OauthHybridUserAgent or OauthUserAgentIdToken. The Setup Login History page shows only recent logins, so query the object for the full six months:

SELECT LoginTime, UserId, Application, LoginSubType, LoginType, Status, SourceIp, Browser
FROM LoginHistory
WHERE LoginTime = LAST_N_DAYS:180
  AND LoginSubType IN ('OauthUserAgent', 'OauthHybridUserAgent', 'OauthUserAgentIdToken')
ORDER BY LoginTime DESC

Group by Application after export.

Salesforce keeps Login History for six months. The value stored in LoginSubType can also differ from the label in Setup, so before you trust an empty result, look up one login you know came from a user-agent client and adjust the IN list to its raw value.

The Application column names the connected app or External Client App. Cross-check it against Setup > Connected Apps OAuth Usage to see how many users each app affects; that page does not show the flow, which is why you need Login History as well. If the app ships in a managed package, the vendor has to fix it. Open a case now and get a version and a date in writing.

Seasonal tools are the usual gap: an extension used only during year-end close may not have logged in within six months. Ask business owners what they use seasonally.

Moving the client to Web Server flow with PKCE

The Web Server flow returns a short-lived authorization code in the query string, and the client exchanges it for tokens at /services/oauth2/token. PKCE ties the two requests together. The client generates a random code_verifier, sends its SHA-256 hash (base64url, method S256) as code_challenge on the authorize request, and presents the original verifier on the token request. Someone who intercepts the code cannot redeem it without the verifier.

Here is the browser side of an internal Case Triage SPA:

// triage-auth.js: runs in the Case Triage SPA
const SF_HOST = 'https://acme.my.salesforce.com';
const CLIENT_ID = '3MVG9_yourConsumerKey';
const REDIRECT_URI = 'https://triage.acme.internal/auth/callback/';

function base64url(bytes) {
  let bin = '';
  bytes.forEach((b) => { bin += String.fromCharCode(b); });
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export async function startTriageLogin() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  const challenge = base64url(new Uint8Array(digest));
  const state = base64url(crypto.getRandomValues(new Uint8Array(16)));

  sessionStorage.setItem('triage_pkce_verifier', verifier);
  sessionStorage.setItem('triage_oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
    scope: 'api refresh_token'
  });
  window.location.assign(`${SF_HOST}/services/oauth2/authorize?${params}`);
}

On the callback page, check for an error and validate state, then hand the code and verifier to the backend:

export async function finishTriageLogin() {
  const qs = new URLSearchParams(window.location.search);
  const expected = sessionStorage.getItem('triage_oauth_state');
  if (qs.get('error')) {
    throw new Error(qs.get('error_description') || qs.get('error'));
  }
  if (!expected || qs.get('state') !== expected) {
    throw new Error('OAuth state mismatch, abandoning login');
  }
  const res = await fetch('/auth/exchange/', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      code: qs.get('code'),
      verifier: sessionStorage.getItem('triage_pkce_verifier')
    })
  });
  sessionStorage.removeItem('triage_pkce_verifier');
  sessionStorage.removeItem('triage_oauth_state');
  if (!res.ok) throw new Error('Token exchange failed');
  window.history.replaceState(null, '', '/');
}

Salesforce enforces the pairing strictly:

  • challenge sent on authorize, no code_verifier on the token request: invalid_grant
  • code_verifier sent when no challenge was sent: invalid_grant
  • code_verifier that does not match the challenge: invalid_grant
  • malformed code_challenge on the authorize request: invalid_request

On the app, turn on "Require Proof Key for Code Exchange (PKCE) extension for Supported Authorization Flows" so a misconfigured client fails loudly at the authorize step. The right value for "Require Secret for Web Server Flow" depends on where the exchange runs, covered below. Use your My Domain host, or login.salesforce.com and test.salesforce.com, for both endpoints, and send an identical redirect_uri on both requests.

Hybrid mobile apps

Apps on the hybrid user-agent flow move to the hybrid web-server flow with PKCE. Which Mobile SDK version made that the default is not yet confirmed, so read the release notes for the SDK version your app ships and test login on a real device against a sandbox with the release update enabled. An app pinned to an old SDK has to upgrade the SDK to change flows.

Where the token exchange runs

The user-agent flow gave a browser client no safe place for a refresh token, so most of these apps simply made the user log in again. The Web Server flow can issue refresh tokens with the refresh_token scope, and now you have to decide where they live.

User-agent flow (retiring) Web Server + PKCE, exchange in browser Web Server + PKCE, exchange on backend
Where the token appears URL fragment, then JS memory or storage Token response delivered to browser JS Backend only; browser holds an httpOnly session cookie
Client secret None None ("Require Secret for Web Server Flow" off) Kept on the server, requirement left on
Refresh token No safe place to store it Stored in the browser Stays on the server
CORS on OAuth endpoints Not needed (redirect only) Needed for the token endpoint Not needed

Salesforce's general CORS allowlist does not apply to unauthenticated resources, and the OAuth endpoints count as unauthenticated (OpenID Connect discovery is the exception). Newer API versions document a separate setting that enables CORS for some OAuth endpoints. I have not verified how it behaves, so if you plan a browser-only exchange, prove in a sandbox that the token endpoint accepts a POST from your origin before committing to that design.

For anything touching customer data I would build the backend column. A backend-for-frontend of about a hundred lines does the exchange, keeps the refresh token in its own session store and gives the browser only a cookie. Because the secret stays server side, you can leave both "Require Secret for Web Server Flow" and "Require Secret for Refresh Token Flow" on. The cost is one more service to deploy and run. A browser-only PKCE client is acceptable for a low-risk internal tool if CORS works in your org and the app has a short refresh token policy.

// server.js: backend-for-frontend for the Case Triage SPA (Node 18+, Express)
app.post('/auth/exchange/', express.json(), async (req, res) => {
  const form = new URLSearchParams({
    grant_type: 'authorization_code',
    code: req.body.code,
    client_id: process.env.SF_CLIENT_ID,
    client_secret: process.env.SF_CLIENT_SECRET,
    redirect_uri: 'https://triage.acme.internal/auth/callback/',
    code_verifier: req.body.verifier
  });

  const sfRes = await fetch('https://acme.my.salesforce.com/services/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: form
  });
  const tokens = await sfRes.json();
  if (!sfRes.ok) {
    return res.status(401).json({ error: tokens.error, detail: tokens.error_description });
  }

  const sid = await triageSessions.create({
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    instanceUrl: tokens.instance_url
  });
  res.cookie('triage_sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
  return res.sendStatus(204);
});

The same exchange in curl, for testing by hand. The code is single use and expires quickly, so take it straight from the callback URL:

curl -s -X POST https://acme.my.salesforce.com/services/oauth2/token \
  -d grant_type=authorization_code \
  --data-urlencode code="$AUTH_CODE" \
  -d client_id="$SF_CLIENT_ID" \
  -d client_secret="$SF_CLIENT_SECRET" \
  --data-urlencode redirect_uri="https://triage.acme.internal/auth/callback/" \
  --data-urlencode code_verifier="$CODE_VERIFIER"

# Later, refresh on the server
curl -s -X POST https://acme.my.salesforce.com/services/oauth2/token \
  -d grant_type=refresh_token \
  -d client_id="$SF_CLIENT_ID" \
  -d client_secret="$SF_CLIENT_SECRET" \
  --data-urlencode refresh_token="$REFRESH_TOKEN"

Cutover timeline and the other February 20 deadlines

February 20, 2027 is a Saturday. I would plan backwards from it:

  1. November: finish the inventory from source control and Login History, and open cases with managed package vendors.
  2. December: enable the release update in a sandbox, build the PKCE client, and test login, refresh and logout end to end. Deploy the app settings (PKCE requirement, secret options, callback URLs) through your normal pipeline.
  3. January: ship each client to production and retest, keeping the old build ready to roll back.
  4. February: freeze auth changes for the week of the 20th and keep checking Login History for leftover user-agent subtypes.

While each app is open, consider moving it from a connected app to an External Client App. Legacy connected apps lose full support in Summer '27 (connected apps end of support), and doing both in one release saves a second test cycle.

The username-password flow retires on the same Saturday; the username-password retirement guide covers its server-to-server replacements. The device flow is a separate, earlier deadline: its removal for connected apps, packaged ECAs and local ECAs without a callback URL lands on November 30, 2026 (device flow details). SOAP login() retires in Summer '27; one secondary source cites June 1, 2027, which Salesforce has not yet confirmed (SOAP login retirement).

What to watch for

  • Tokens issued before enforcement should keep working until they expire or the session times out, so failures will show up at the login screen and in helpdesk tickets. I have not found the error text published, so brief the service desk early.
  • A client missing from the Login History query may still be in use, given the six-month window.
  • The authorization code arrives in the query string. Callback code that still reads location.hash finds nothing.
  • Validate state on every callback. PKCE protects the code; state protects the user from a forged login.
  • Check Setup > Release Updates monthly in case the enforcement date moves.

Originally reported by help.salesforce.com

Frequently asked questions

When does Salesforce retire the OAuth user-agent flow?

Enforcement of the user-agent and hybrid user-agent flow retirement is February 20, 2027. The release update first appeared in late Summer '26. Check Setup > Release Updates in your org for the current date, because Salesforce has already moved a sibling OAuth deadline once.

How do I find which apps use the user-agent flow in Salesforce?

Query LoginHistory for the LoginSubType values OauthUserAgent, OauthHybridUserAgent and OauthUserAgentIdToken, and read the Application field. Look up one login you know came from a user-agent client to confirm the stored value matches. Login History only keeps six months, so also grep your code for response_type=token and ask owners about tools they use only now and then.

Does the Salesforce Web Server flow with PKCE need a client secret?

The token request requires client_secret unless you turn off "Require Secret for Web Server Flow" on the app, which is what a browser-only SPA or mobile app does. If a backend does the exchange, leave the requirement on and keep the secret on the server.

Will existing Salesforce access tokens stop working on February 20, 2027?

I expect tokens issued before the date to keep working until they expire or the session times out, with new logins through the old flow failing at the authorize step. I have not found the exact error text published, so test with the release update enabled in a sandbox.

Can a React SPA call the Salesforce OAuth token endpoint directly?

Only if your org accepts cross-origin requests to the token endpoint. The general CORS allowlist does not cover OAuth endpoints and Salesforce documents a separate setting for some of them, so confirm it in a sandbox or run the exchange on a small backend.

What replaces the hybrid user-agent flow in Salesforce Mobile SDK apps?

The hybrid web-server flow with PKCE. Check the release notes for the Mobile SDK version you ship to see which flow it uses, then test login on a real device against a sandbox.

Newsletter

One email every Tuesday

New guides, tool updates, and the release-note changes that break things.

No spam. Unsubscribe in one click.

Comments

Loading comments...

Leave a Comment