Skip to content

Salesforce Restricts the OAuth Device Flow on 30 November 2026: Your Connected App Has to Become an External Client App

From 30 November 2026 the OAuth device flow works only from local external client apps. Connected apps that use it must migrate or stop authenticating.

TL;DR

  • From 30 November 2026, the OAuth 2.0 device flow works only from local external client apps with a localhost callback URL.
  • Salesforce's wording leaves no room to wait it out: "For connected apps, you must migrate to external client apps to continue using the device flow."
  • This is not a Winter '27 item. It has its own fixed date, so it lands on every org at once, not on your upgrade weekend.
  • This is the leading edge of a larger migration. Migrate All Connected Apps to External Client Apps is scheduled for Summer '27, so the work is early, not extra.
  • The device flow is used by things without a browser: headless scripts, CI runners, IoT devices, kiosks, anything that asks you to visit a URL and type a code. If nothing in your org does that, this costs you one query to confirm.

What You'll Learn

  • How to confirm in one query whether any app in your org uses the device flow
  • What "local external client app with a localhost callback" actually excludes
  • Why the migration is not optional and where it leads next
  • How this relates to the other OAuth retirements already in flight

The Problem

The device flow exists for clients that cannot host a browser redirect. A CI runner, a script on a server, a device with no keyboard. It works by having the client show a code, the human authenticate elsewhere, and the client poll until the authorisation lands. That indirection is exactly what makes it useful, and exactly what makes it attractive to an attacker: a code displayed on one screen and typed on another is a code that can be displayed on a screen the victim does not control.

Salesforce's response is to constrain where the flow can originate. From 30 November 2026 it is restricted to local external client apps with a localhost callback URL. That combination is deliberate. It permits the legitimate case, a tool running on the machine of the person authenticating, and removes the case where a remote server initiates a flow that a human somewhere else completes.

The part that turns this from a configuration change into a project is the app type. Connected apps cannot satisfy the new criteria at all. Salesforce is explicit: to keep using the device flow, a connected app has to become an external client app.

Common questions this article answers:

  • Does anything in my org actually use the device flow?
  • Why can a connected app not simply keep using it?
  • What do I do about a headless client that has no local user?

Quick Answer

From 30 November 2026, Salesforce restricts the OAuth 2.0 device flow to local external client apps with a localhost callback URL. Connected apps using the device flow must be migrated to external client apps or they stop authenticating. The date is fixed, not tied to your org's release upgrade, so it applies everywhere on the same day. Confirm your exposure in Setup under Connected Apps OAuth Usage, and cross-check Login History for the app names you find, since an app that has not run recently still breaks when it next runs. If you find nothing using the device flow, you are done. If you find something, the migration to an external client app is work you were going to do anyway, because Migrate All Connected Apps to External Client Apps is scheduled for Summer '27 and covers everything else.

Step 1: Find out whether this applies to you

Most orgs will find nothing. Confirming that takes a minute and is worth doing properly instead of assuming.

Start in Setup, under Connected Apps OAuth Usage. That page lists every connected app that has been used to authenticate, which is a smaller and more useful list than every app that exists. For each one that matters, open it and look at the OAuth settings for the enabled flows.

Then check what has actually been authenticating:

SELECT UserId, Application, Status, COUNT(Id) logins
FROM LoginHistory
WHERE LoginTime = LAST_N_DAYS:180
GROUP BY UserId, Application, Status
ORDER BY COUNT(Id) DESC

A note that will save you a parse error: on LoginHistory, Application, Status and ApiType are groupable but not filterable, so you cannot put Application in the WHERE clause. Group first and read the result. The filterable fields are LoginTime, UserId, LoginType, SourceIp and LoginUrl.

Use 180 days rather than 90 here. Device flow clients are frequently the least routine things in an org: a quarterly reconciliation runner, a device that gets switched on for stocktake. A short window is exactly how you miss them.

A script that does the triage for you

Clicking through App Manager works for five connected apps and stops working somewhere around thirty. This does the same job from the command line, and it covers both deadlines at once, because they land on the same inventory: the device flow restriction on 30 November 2026, and the general retirement of connected apps in Summer '27.

Retrieve the metadata first, then run the script against it:

sf project retrieve start --metadata ConnectedApp --target-org yourorg
node connected-app-triage.mjs --target-org yourorg

It is read-only. It runs sf commands you could run by hand and writes nothing to your org.

#!/usr/bin/env node
// Connected app migration triage.
//
// Salesforce is retiring connected apps in Summer '27, and restricting the OAuth
// device flow to local external client apps earlier, on 30 November 2026. This
// sorts every connected app in an org into one of four actions so you can see
// which deadline each one belongs to, and which ones you can delete instead of
// migrating at all.
//
// Usage:
//   sf project retrieve start --metadata ConnectedApp --target-org yourorg
//   node connected-app-triage.mjs --target-org yourorg
//
// Read-only. It runs sf commands you could run yourself and writes nothing.

import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { execFileSync } from 'node:child_process'

const arg = (flag, fallback) =>
  process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : fallback

const org = arg('--target-org', null)
const root = arg('--dir', 'force-app')
const days = arg('--days', '180')

// ---------------------------------------------------------------------------

function findXml(dir, out = []) {
  let entries
  try {
    entries = readdirSync(dir, { withFileTypes: true })
  } catch {
    return out
  }
  for (const e of entries) {
    const p = join(dir, e.name)
    if (e.isDirectory()) findXml(p, out)
    else if (e.name.endsWith('.connectedApp-meta.xml')) out.push(p)
  }
  return out
}

function tag(xml, name) {
  const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
  return m ? m[1].trim() : null
}

// The device flow indicator is discovered, not hardcoded: any element whose name
// contains "device" and carries a boolean counts. If Salesforce renames it this
// keeps working, and if no such element exists anywhere the script says so rather
// than reporting every app as clean. A silent absence and a renamed field look
// identical otherwise, and only one of them means you are safe.
function deviceFlags(xml) {
  return [...xml.matchAll(/<([a-zA-Z]*[Dd]evice[a-zA-Z]*)>(true|false)<\/\1>/g)].map((m) => ({
    element: m[1],
    value: m[2] === 'true',
  }))
}

// LoginType is filterable, so OAuth logins can be isolated in the query. Application
// is groupable but NOT filterable, so it stays out of the WHERE clause; putting it
// there returns a parse error.
function oauthLogins() {
  if (!org) return null
  const q =
    `SELECT Application, COUNT(Id) logins FROM LoginHistory ` +
    `WHERE LoginTime > LAST_N_DAYS:${days} AND LoginType LIKE 'OAuth%' GROUP BY Application`
  try {
    const out = execFileSync('sf', ['data', 'query', '--query', q, '--target-org', org, '--json'], {
      encoding: 'utf8',
      maxBuffer: 32 * 1024 * 1024,
      stdio: ['ignore', 'pipe', 'ignore'],
    })
    const parsed = JSON.parse(out)
    if (parsed.status !== 0) return null
    const map = new Map()
    for (const r of parsed.result.records) {
      if (r.Application) map.set(r.Application.toLowerCase(), r.logins)
    }
    return map
  } catch {
    return null
  }
}

// ---------------------------------------------------------------------------

const files = findXml(root)
if (files.length === 0) {
  console.error(`No connected app metadata under ${root}.`)
  console.error(`  sf project retrieve start --metadata ConnectedApp --target-org ${org || 'yourorg'}`)
  process.exit(2)
}

const activity = oauthLogins()
const known = activity === null ? null : true

const apps = files.map((f) => {
  const xml = readFileSync(f, 'utf8')
  const name = f.split('/').pop().replace('.connectedApp-meta.xml', '')
  const label = tag(xml, 'label') || name
  const flags = deviceFlags(xml)
  const callback = tag(xml, 'callbackUrl') || ''
  // LoginHistory records the app under its name, which may be the label or the API
  // name depending on how the app was created, so both are checked.
  const logins = known
    ? (activity.get(label.toLowerCase()) ?? activity.get(name.toLowerCase()) ?? 0)
    : null
  return {
    name,
    label,
    callback,
    localhost: /^https?:\/\/localhost[:/]/i.test(callback),
    device: flags.some((x) => x.value),
    flags: flags.map((x) => `${x.element}=${x.value}`),
    logins,
  }
})

// ---------------------------------------------------------------------------
// Four actions. Usage-unknown never produces a disable or delete recommendation,
// because "not checked" is not the same as "not used" and treating it that way
// switches off live integrations.
// ---------------------------------------------------------------------------

const ACTIONS = {
  MIGRATE_NOW: {
    order: 1,
    label: 'MIGRATE BY 30 NOV 2026',
    note: 'Device flow, in use, localhost callback. Migrate to a local external client app.',
  },
  REPLACE_FLOW: {
    order: 2,
    label: 'CHANGE THE FLOW',
    note: 'Device flow, in use, non-localhost callback. No local user, so a new app type will not fix this. Move to client credentials or JWT bearer.',
  },
  DISABLE: {
    order: 3,
    label: 'DISABLE THE FLOW',
    note: 'Device flow enabled but no OAuth logins in the window. Turning the flow off is cheaper than migrating it.',
  },
  MIGRATE_SUMMER: {
    order: 4,
    label: 'MIGRATE BY SUMMER 27',
    note: 'In use, no device flow. Covered by the general connected app retirement.',
  },
  DELETE: {
    order: 5,
    label: 'CONSIDER DELETING',
    note: 'No OAuth logins in the window. Deleting is faster than migrating, once you have confirmed it is genuinely unused.',
  },
  UNKNOWN: {
    order: 6,
    label: 'USAGE NOT CHECKED',
    note: 'Re-run with --target-org so usage can be read before you decide anything.',
  },
}

function classify(a) {
  if (!known) return a.device ? (a.localhost ? 'MIGRATE_NOW' : 'REPLACE_FLOW') : 'UNKNOWN'
  if (a.device) {
    if (!a.logins) return 'DISABLE'
    return a.localhost ? 'MIGRATE_NOW' : 'REPLACE_FLOW'
  }
  return a.logins ? 'MIGRATE_SUMMER' : 'DELETE'
}

for (const a of apps) a.action = classify(a)

const groups = new Map()
for (const a of apps) {
  if (!groups.has(a.action)) groups.set(a.action, [])
  groups.get(a.action).push(a)
}

console.log(`\nConnected apps: ${apps.length}`)
if (!known) {
  console.log('OAuth usage:    not checked (no --target-org)')
} else {
  console.log(`OAuth usage:    last ${days} days`)
}
if (!apps.some((a) => a.flags.length)) {
  console.log('\nNo device-flow element appeared in any app. That usually means none')
  console.log('enable it. Spot-check one app in Setup, App Manager, against its XML')
  console.log('before trusting a clean result, since an absent element and a renamed')
  console.log('one look identical from here.')
}
console.log()

for (const key of Object.keys(ACTIONS).sort((a, b) => ACTIONS[a].order - ACTIONS[b].order)) {
  const list = groups.get(key)
  if (!list || list.length === 0) continue
  console.log(`${ACTIONS[key].label}  (${list.length})`)
  console.log(`  ${ACTIONS[key].note}`)
  for (const a of list) {
    const bits = [a.name]
    if (a.callback) bits.push(a.callback)
    if (known) bits.push(`${a.logins} OAuth login(s)`)
    if (a.flags.length) bits.push(a.flags.join(','))
    console.log(`    - ${a.label}`)
    console.log(`        ${bits.join('  |  ')}`)
  }
  console.log()
}

if (activity === null && org) {
  console.log('Login History was not readable, so every app is reported as usage not checked.')
  console.log('The device flow column is still valid.\n')
}

console.log('Deadlines: device flow 30 November 2026, connected apps Summer 2027.')

Output groups every app by what you should do with it:

Connected apps: 5
OAuth usage:    last 180 days

MIGRATE BY 30 NOV 2026  (1)
  Device flow, in use, localhost callback. Migrate to a local external client app.
    - Local CLI Tool
        Local_CLI_Tool  |  http://localhost:1717/OauthRedirect  |  43 OAuth login(s)

CHANGE THE FLOW  (1)
  Device flow, in use, non-localhost callback. No local user, so a new app type
  will not fix this. Move to client credentials or JWT bearer.
    - Build Server Job
        Build_Server_Job  |  https://ci.example.com/callback  |  12 OAuth login(s)

MIGRATE BY SUMMER 27  (1)
  In use, no device flow. Covered by the general connected app retirement.
    - Reporting Tool
        Reporting_Tool  |  https://bi.example.com/cb  |  501 OAuth login(s)

CONSIDER DELETING  (2)
  No OAuth logins in the window. Deleting is faster than migrating.

The last group is usually the biggest and it is the one worth running this for. Most orgs carry connected apps nobody has authenticated against in years, and deleting one is faster than migrating it.

Three design choices worth explaining, because they affect how much you should trust the output.

The device flow element is discovered, not hardcoded. The script matches any element under the OAuth config whose name contains "device", instead of looking for one specific tag. If Salesforce renames the element the script keeps working, and if it finds no device element anywhere it says so rather than reporting every app as clean. A silent absence and a renamed field look identical otherwise, and only one of them means you are safe.

It refuses to guess about usage. Run without --target-org and it does not read Login History, so it will not tell you an app looks unused. An earlier version did, and that is advice that gets a live integration switched off. Without the org, every app that is not obviously device-flow lands in a "usage not checked" group instead.

LoginType is filterable and Application is not. The query filters to LoginType LIKE 'OAuth%' so browser and CLI logins do not count as connected app usage, then groups by Application and matches in memory. Putting Application in the WHERE clause returns a parse error, which is the same trap described above.

One limitation to know. The script matches Login History records to apps by name, checking both the label and the API name, because Salesforce records the application under one or the other depending on how the app was created. An app whose recorded name matches neither will show as unused when it is not, so treat a surprising entry in the delete group as something to confirm in Setup before acting on it.

Step 2: Understand what the new criteria exclude

Two words are doing the work: local and localhost.

A callback of http://localhost:1717/OauthRedirect qualifies. A callback pointing at a server you host, a container, or a public URL does not, no matter how well secured. If the client is not running on the same machine as the person authenticating, the flow is no longer available to it after 30 November.

This is worth reading carefully before planning a migration, because it changes the answer for some architectures, not just the app type. A headless job on a build server that used the device flow to obtain a token cannot simply be converted into an external client app and carry on. It has no local human and no localhost. That integration needs a different flow entirely, and the sensible destinations are the client credentials flow for server to server work, or the JWT bearer flow where a certificate is preferable to a stored secret. Both are covered in the same terms in the username-password flow retirement guide, which is the other place this decision comes up.

Step 3: Migrate, knowing where it leads

If you do have a genuine local device flow client, the migration is from connected app to external client app.

External client apps are the newer packaging for the same idea, with app configuration separated from policy so the two can be versioned and deployed independently. That separation is the actual reason Salesforce is moving everyone across, and it is why this is not a one-off favour to the device flow.

The scheduling makes the direction obvious. This update enforces on 30 November 2026 for device flow apps specifically. Migrate All Connected Apps to External Client Apps is scheduled for Summer '27 and covers the rest. Doing the device flow apps now is not extra work, it is the first tranche of a migration with a later deadline attached to everything else. Teams that treat it that way get to learn the migration on one or two apps instead of all of them at once.

Where this sits among the other OAuth changes

Salesforce is retiring or restricting several OAuth flows at once, and the dates are not the same:

Change Enforced
Device flow restricted to local external client apps 30 November 2026
Username-password (ROPC) flow retirement 20 February 2027
User-agent and hybrid user-agent flow retirement 20 February 2027
Migrate all connected apps to external client apps Summer '27

The common direction is away from flows that pass credentials around and towards flows that use a redirect with PKCE, or that authenticate a service rather than a user. If you are opening integration configuration for one of these, it is usually cheaper to survey all of them in the same pass than to come back in February.

Frequently Asked Questions

Q: Does this affect the Salesforce CLI?

A: Not in the versions this post was written against. sf org login offers web, jwt, sfdx-url and access-token, with no device flow subcommand, so CLI authentication is not in scope here. Check your own CLI version rather than taking that as permanent, since the command set changes between releases.

Q: We have a connected app with the device flow enabled but nothing uses it. Do we have to migrate?

A: No, and this is the better outcome. An enabled flow that nothing uses is a permission you can remove rather than migrate. Turn the device flow off in the connected app's OAuth settings, confirm nothing breaks over a full business cycle, and you have reduced the app's surface instead of moving it. That is a better result than a migration, and it takes a fraction of the time.

Q: Is this a Winter '27 release update?

A: No. It sits under Scheduled To Be Enforced Before Spring '27, with its own fixed date of 30 November 2026. That distinction matters for planning: release updates tied to a release arrive on your org's upgrade weekend, which varies by instance, while this arrives everywhere on the same day regardless of which release your org is running.

Q: What actually happens on the date if we do nothing?

A: Affected clients stop being able to complete the device flow. The failure is at authorisation, not mid-transaction, so there is nothing partial to unpick, and as with the other OAuth changes there is no signal in the Salesforce UI. Whoever operates the device or the script finds out first.

Q: Should we wait, given how often these dates move?

A: The instanced URL update has now slipped three times, so the scepticism is earned. It is misplaced here for a practical reason: the work does not expire. Migrating a connected app to an external client app is required by Summer '27 regardless of what happens to this date, so effort spent now is not wasted if the deadline moves. That is the opposite of the usual wait-and-see calculation.

Key Takeaways

  • 30 November 2026 is a fixed date, not an upgrade-weekend date. It lands on every org simultaneously.
  • Connected apps cannot meet the new criteria. Migration to an external client app is the only path that keeps the device flow.
  • Local and localhost are both requirements. A headless client on a remote server needs a different flow, not a different app type.
  • Most orgs are not affected, and confirming that costs one query against Connected Apps OAuth Usage and Login History.
  • This is the first tranche of a bigger migration, with everything else due in Summer '27, so early work here is not wasted even if the date moves.

What's Next?

Run the Connected Apps OAuth Usage check today. If it comes back clean, note the date and move on. If it does not, decide per app whether you are migrating it or switching it off, because switching off an unused flow is the cheaper answer and it comes up more often than people expect.

Then read the wider release picture in Salesforce Winter '27 Security Readiness, and if you have integrations authenticating with a username and password, that retirement shares both a date and a destination with the user-agent flow changes.

Resources & References

  • Salesforce Release Updates, under Scheduled To Be Enforced Before Spring '27, in Salesforce Help. Confirm the status for your own org in Setup, then Release Updates.
  • Connected Apps OAuth Usage in Setup, for which apps have actually authenticated.
  • LoginHistory object reference, for the filterable and groupable flags that shape the query above.
  • Salesforce Winter '27 Security Readiness for the release as a whole.
  • The username-password flow retirement for the client credentials and JWT bearer alternatives in detail.

Responses

Checking your session.

Loading responses.