If you have some old code quietly connecting to Salesforce with a client ID, client secret, username and password, you have a deadline coming.
Salesforce is retiring the OAuth 2.0 Username-Password Flow for Connected Apps on 20 February 2027. Once the release update is enforced, integrations which still authenticate using grant_type=password will simply stop authenticating. Newer Salesforce orgs are already affected, with the flow disabled for orgs created from Summer ’26 onward.
This is particularly relevant to the enormous pile of unattended integrations written over the last decade or so. Scripts, Laravel applications, scheduled jobs, middleware and little bits of glue code often use the Username-Password Flow precisely because it was so easy: store four or five values in environment variables, POST them to Salesforce, get an access token, and get on with doing useful work.
Unfortunately, that simplicity came with a fairly obvious security problem: your application has to store an actual Salesforce user’s password.
For most server-to-server integrations, the replacement you probably want is the OAuth Client Credentials Flow. Instead of storing a Salesforce username and password, your application authenticates using its own client ID and client secret, and Salesforce runs the connection as a nominated integration user.
There is also the JWT Bearer Flow, which is useful where you’d rather authenticate using a private key and certificate rather than a long-lived shared client secret.
For applications where an actual human is authorising access to their Salesforce account, Salesforce recommends the Web Server / Authorization Code flow with PKCE instead. That’s a different use case to the unattended Laravel applications and cron jobs I’m mainly talking about here.
So, the short version is:
If your Salesforce integration sends grant_type=password, you need to change it.
And if you’re using an older Salesforce PHP package, changing the Salesforce configuration may only be half the job.
What is actually being retired?
The old OAuth Username-Password Flow looks roughly like this:
Application
|
| client_id
| client_secret
| username
| password
v
Salesforce OAuth endpoint
|
| access_token
v
Salesforce REST API
Unlike most OAuth flows there is no browser redirect, authorization page or refresh token. Your application simply sends a Salesforce username and password directly to the token endpoint.
Salesforce has considered this flow insecure for some time. It requires the application to know and store the user’s actual credentials, and doesn’t fit particularly well with things like MFA. Salesforce’s current documentation recommends using it only for special scenarios and explicitly recommends Client Credentials or Web Server + PKCE instead.
The deadline is now quite concrete:
20 February 2027.
This isn’t the same thing as Salesforce’s broader move away from legacy Connected Apps. Existing Connected Apps can continue functioning, but Salesforce is separately moving to External Client Apps, and Connected Apps reach end-of-support in Summer ’27. At that point Salesforce says they will continue working, but fixes and support for the framework and its authorization flows will end.
If you’re changing an integration now, therefore, it makes sense to fix both problems at once and use an External Client App rather than building something new around the legacy Connected App framework.
What the old request looks like
A typical Username-Password Flow request looks something like:
export SF_LOGIN_URL="https://login.salesforce.com"
export SF_CLIENT_ID="your-consumer-key"
export SF_CLIENT_SECRET="your-consumer-secret"
export SF_USERNAME="integration@example.com"
export SF_PASSWORD="your-password"
export SF_SECURITY_TOKEN="your-security-token"
curl -sS -X POST \
"$SF_LOGIN_URL/services/oauth2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode "client_id=$SF_CLIENT_ID" \
--data-urlencode "client_secret=$SF_CLIENT_SECRET" \
--data-urlencode "username=$SF_USERNAME" \
--data-urlencode "password=${SF_PASSWORD}${SF_SECURITY_TOKEN}"
The security token isn’t always required, depending on your Salesforce network and login configuration, but where it is required Salesforce expects it to be concatenated onto the end of the password.
A successful response contains values along these lines:
{
"access_token": "00D...",
"instance_url": "https://example.my.salesforce.com",
"id": "https://login.salesforce.com/id/00D.../005...",
"token_type": "Bearer"
}
You then use that token for API calls:
curl -sS \
"$SF_INSTANCE_URL/services/data/v68.0/query/?q=SELECT+Id,Name+FROM+Account+LIMIT+10" \
-H "Authorization: Bearer $SF_ACCESS_TOKEN"
The REST API part of your application doesn’t fundamentally change when you move to another OAuth flow. What changes is how you obtain that bearer token.
That distinction becomes quite useful when looking at existing PHP libraries.
Option 1: Client Credentials
For a normal server-to-server integration, this is probably the simplest replacement.
Instead of this:
client ID + secret + Salesforce username + Salesforce password
you have:
client ID + secret
|
v
External Client App
|
v
nominated Run As user
There is still a Salesforce user involved because Salesforce needs a security context in which to execute your API calls, but your application no longer authenticates by pretending to log in as that user.
You configure the Run As or integration user within Salesforce and give that account only the permissions the integration actually requires. Salesforce specifically recommends using an API-only integration user where appropriate.
Creating the External Client App
In Salesforce Setup, create an External Client App and enable OAuth.
Enable the Client Credentials Flow, assign the appropriate OAuth scopes, and configure the integration user that the client will run as.
For an integration using the REST API you’ll normally require at least the API scope, with the actual Salesforce object and field permissions controlled by the integration user’s profile and permission sets.
One important difference from the old flow is the URL you authenticate against.
For Client Credentials, Salesforce specifically says that login.salesforce.com and test.salesforce.com are not supported. You need to send the request to your My Domain URL instead.
For example:
export SF_DOMAIN="https://mycompany.my.salesforce.com"
export SF_CLIENT_ID="your-consumer-key"
export SF_CLIENT_SECRET="your-consumer-secret"
Salesforce supports two ways of presenting the client credentials.
Client Credentials using POST parameters
This is probably the most obvious translation of the old code:
curl -sS -X POST \
"$SF_DOMAIN/services/oauth2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$SF_CLIENT_ID" \
--data-urlencode "client_secret=$SF_CLIENT_SECRET"
That’s it.
No username.
No password.
No Salesforce security token.
A successful response gives you an access token and instance URL which can be used for the REST API exactly as before.
Client Credentials using HTTP Basic authentication
Salesforce also allows the client ID and secret to be supplied using HTTP Basic authentication rather than putting them into the request body.
With curl that’s even simpler:
curl -sS -X POST \
"$SF_DOMAIN/services/oauth2/token" \
-u "$SF_CLIENT_ID:$SF_CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials'
curl -u creates the appropriate HTTP Authorization: Basic ... header for you.
I prefer this form simply because it separates the client authentication from the OAuth request parameters, although both are supported by Salesforce.
There is no refresh token with Client Credentials. If your access token expires, the application simply authenticates again using the client credentials and obtains another one.
For an unattended service this is actually pleasantly simple.
Option 2: JWT Bearer authentication
Client Credentials still gives you a long-lived secret which can obtain a Salesforce access token if somebody gets hold of it.
If you’d prefer asymmetric authentication, Salesforce also supports the OAuth JWT Bearer Flow for server-to-server integrations.
This works differently:
Laravel application
|
| signs short-lived JWT
| using private key
v
Salesforce
|
| verifies signature
| using public certificate
v
access token
Your private key stays on the application server. Salesforce only needs the public certificate.
That means there is no reusable password or client secret being transmitted during authentication.
The JWT contains, amongst other things, your OAuth client ID as the issuer, the Salesforce user as the subject, the Salesforce authorization server as the audience, and a short expiry time. Salesforce requires the assertion to be signed using RSA SHA-256.
A rough shell example looks like this.
First, assuming you’ve already created an RSA private key and uploaded its corresponding X.509 certificate to your External Client App:
export SF_CLIENT_ID="your-consumer-key"
export SF_USERNAME="integration@example.com"
export SF_AUTH_URL="https://login.salesforce.com"
export SF_PRIVATE_KEY="./salesforce.key"
Then create a short-lived JWT:
b64url()
{
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
HEADER=$(printf '%s' '{"alg":"RS256"}' | b64url)
EXP=$(($(date +%s) + 180))
PAYLOAD=$(
jq -nc \
--arg iss "$SF_CLIENT_ID" \
--arg sub "$SF_USERNAME" \
--arg aud "$SF_AUTH_URL" \
--argjson exp "$EXP" \
'{iss:$iss,sub:$sub,aud:$aud,exp:$exp}'
)
PAYLOAD=$(printf '%s' "$PAYLOAD" | b64url)
SIGNATURE=$(
printf '%s' "$HEADER.$PAYLOAD" |
openssl dgst -sha256 -sign "$SF_PRIVATE_KEY" -binary |
b64url
)
JWT="$HEADER.$PAYLOAD.$SIGNATURE"
And exchange it for an access token:
curl -sS -X POST \
"$SF_AUTH_URL/services/oauth2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode \
'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode \
"assertion=$JWT"
Salesforce validates the signature against the certificate registered with the application and, assuming the user/application has already been authorised appropriately, returns a normal OAuth bearer token. The JWT Bearer Flow doesn’t issue a refresh token either.
There are some additional setup details around pre-authorisation, certificate rotation and the exact audience value, particularly if you’re using My Domain or an Experience Cloud domain, so this is one place where I’d strongly suggest checking Salesforce’s current JWT documentation rather than blindly copying an old example from Stack Overflow.
The important distinction is that the private key never needs to leave your application server.
What about Web Server Flow and PKCE?
Salesforce’s release notice actually gives two primary recommendations.
For end-user login and authorization, use the Web Server Flow with PKCE.
For server-to-server integrations, use Client Credentials.
If you have a Laravel application where users click something like Connect Salesforce, get sent to Salesforce, approve access, and then come back to your application, you should be looking at Authorization Code + PKCE rather than Client Credentials.
That’s roughly:
Browser
|
v
Salesforce authorization page
|
| authorization code
v
Laravel callback
|
| code + PKCE verifier
v
Salesforce token endpoint
|
v
access token / refresh token
That’s a substantially different problem from a backend service account running a scheduled sync every five minutes.
For the latter, adding browser redirects merely to replace grant_type=password would be solving the wrong problem.
And then there’s Laravel…
This is where things get a bit more interesting.
A number of PHP Salesforce libraries were written when the Username-Password Flow was an entirely normal way of connecting an unattended application to Salesforce.
One example is ae/salesforce-rest-sdk.
Its own documentation shows the normal client being constructed like this:
use AE\SalesforceRestSdk\Rest\Client;
use AE\SalesforceRestSdk\AuthProvider\OAuthProvider;
$client = new Client(
new OAuthProvider(
'SF_CLIENT_ID',
'SF_CLIENT_SECRET',
'https://login.salesforce.com',
'SF_USER',
'SF_PASS'
)
);
And unfortunately, that isn’t just an old README example.
At the time of writing, the OAuthProvider itself defines only:
public const GRANT_PASSWORD = "password";
public const GRANT_CODE = "authorization_code";
and defaults to GRANT_PASSWORD. Its password branch explicitly POSTs grant_type, client_id, client_secret, username and password to /services/oauth2/token.
So changing your Salesforce configuration from Username-Password to Client Credentials and deleting SALESFORCE_USERNAME from .env is not enough.
The library doesn’t currently know how to perform the Client Credentials grant.
The good news is that you don’t have to throw the rest of the SDK away.
The AuthProvider interface makes migration relatively painless
Internally, ae/salesforce-rest-sdk‘s REST client depends on AuthProviderInterface, rather than explicitly requiring OAuthProvider.
That interface mostly needs to be able to:
authorize();
reauthorize();
revoke();
getToken();
getTokenType();
getInstanceUrl();
getIdentity();
isAuthorized();
The REST client asks the provider for an authorization header and an instance URL, then gets on with doing REST things.
It even automatically calls reauthorize() and retries the request when Salesforce responds with a 401.
That means one migration path is simply to write a small Client Credentials provider.
For example:
<?php
namespace App\Services\Salesforce;
use AE\SalesforceRestSdk\AuthProvider\AuthProviderInterface;
use GuzzleHttp\Client;
class ClientCredentialsProvider implements AuthProviderInterface
{
protected Client $http;
protected ?string $token = null;
protected ?string $instanceUrl = null;
protected string $tokenType = 'Bearer';
public function __construct(
protected string $clientId,
protected string $clientSecret,
string $salesforceDomain
) {
$this->http = new Client([
'base_uri' => rtrim($salesforceDomain, '/'),
]);
}
public function authorize()
{
if ($this->token !== null) {
return "{$this->tokenType} {$this->token}";
}
$response = $this->http->post(
'/services/oauth2/token',
[
'auth' => [
$this->clientId,
$this->clientSecret,
],
'form_params' => [
'grant_type' => 'client_credentials',
],
'headers' => [
'Accept' => 'application/json',
],
]
);
$data = json_decode(
(string) $response->getBody(),
true
);
$this->token = $data['access_token'];
$this->tokenType = $data['token_type'] ?? 'Bearer';
$this->instanceUrl = $data['instance_url'];
return "{$this->tokenType} {$this->token}";
}
public function reauthorize()
{
$this->token = null;
return $this->authorize();
}
public function revoke()
{
$this->token = null;
$this->instanceUrl = null;
}
public function getIdentity(): array
{
return [];
}
public function getToken(): ?string
{
return $this->token;
}
public function getTokenType(): ?string
{
return $this->tokenType;
}
public function isAuthorized(): bool
{
return $this->token !== null;
}
public function getInstanceUrl(): ?string
{
return $this->instanceUrl;
}
}
Then your existing Salesforce client can remain almost completely unchanged:
use AE\SalesforceRestSdk\Rest\Client;
use App\Services\Salesforce\ClientCredentialsProvider;
$provider = new ClientCredentialsProvider(
config('services.salesforce.client_id'),
config('services.salesforce.client_secret'),
config('services.salesforce.domain')
);
$salesforce = new Client(
$provider,
config('services.salesforce.api_version')
);
And your Laravel configuration might look something like:
// config/services.php
return [
// ...
'salesforce' => [
'domain' => env('SALESFORCE_DOMAIN'),
'client_id' => env('SALESFORCE_CLIENT_ID'),
'client_secret' => env('SALESFORCE_CLIENT_SECRET'),
'api_version' => env('SALESFORCE_API_VERSION'),
],
];
with:
SALESFORCE_DOMAIN=https://mycompany.my.salesforce.com
SALESFORCE_CLIENT_ID=...
SALESFORCE_CLIENT_SECRET=...
SALESFORCE_API_VERSION=68.0
No Salesforce username or password needs to live in the Laravel application’s environment any more.
You could bind the Salesforce client into Laravel’s service container as a singleton if it’s used extensively throughout the application.
The same general approach applies to other Laravel Salesforce packages: find the part which obtains the access token.
If the package supports client_credentials, fantastic. Change the configuration and you’re probably nearly done.
If it exposes an authentication-provider interface, write a provider.
If OAuth is deeply baked into the package and grant_type=password is hard-coded, you may need to upgrade, fork or replace it.
What you generally shouldn’t do is rewrite every Salesforce query in your application just because its authentication mechanism changed.
One other thing to check while you’re in there
ae/salesforce-rest-sdk is showing its age in a few other places.
The latest official ae/salesforce-rest-sdk release on Packagist is still 2.0.1 from April 2020, requiring PHP 7.2, Guzzle 6 and Symfony 4/5 components.
Its REST client also defaults to Salesforce API version 44.0 unless you explicitly specify a version.
API v44.0 is still supported at the time of writing, but Salesforce’s API retirement policy means old versions don’t live forever. Salesforce currently lists versions 41.0 through 68.0 as supported.
So if you’re already touching a six-year-old Salesforce integration to fix OAuth, it’s worth checking which REST API version it is actually using rather than blindly preserving another historical default.
You can ask Salesforce what versions are available with:
curl -sS \
"$SF_INSTANCE_URL/services/data/" \
-H "Authorization: Bearer $SF_ACCESS_TOKEN"
Then deliberately configure a supported API version your application has been tested against.
Which flow should I choose?
For the sorts of integrations I tend to deal with, the decision is reasonably straightforward:
- Scheduled job, Laravel backend, middleware or system-to-system sync: use Client Credentials unless you have a particular reason not to.
- Higher-security server-to-server integration where you want asymmetric credentials: consider JWT Bearer.
- A user explicitly connects their own Salesforce account to your application: use Authorization Code / Web Server Flow with PKCE.
- Anything still using
grant_type=password: put it on the migration list now. - Anything creating a new Connected App: use an External Client App instead unless you have a specific legacy requirement.
The nice thing is that for most server-to-server REST integrations the big scary OAuth migration isn’t actually that big.
Your SOQL doesn’t change.
Your SObjects don’t change.
Your REST endpoints don’t change.
Your business logic doesn’t change.
You’re replacing the small piece of code which obtains the bearer token.
The potentially dangerous part is assuming that because a PHP or Laravel Salesforce package still works today, its authentication method is going to continue working next year.
The date to remember
20 February 2027.
After that date, Salesforce’s OAuth Username-Password Flow for Connected Apps is gone.
If you have an integration containing something resembling:
$clientId,
$clientSecret,
$username,
$password
or:
grant_type=password
now would be an excellent time to work out why.
Because on 19 February it’ll still look like a boring piece of code which has quietly run for years.
On 20 February it may become a considerably more interesting piece of code.
