Laravel Passport v13: the plain-secret storage change that broke my OAuth flow
Laravel Passport v13 shipped with a breaking change that isn't obvious from a quick glance at the upgrade guide: client secrets are now hashed by default, the same way user passwords are. If you upgrade an existing app without handling this properly, every OAuth consumer that was working yesterday will fail authentication today.
This post covers what actually changed, why it's a security improvement, and the migration path that avoids breaking production.
What changed
Before v13, Passport stored OAuth client secrets in one of two ways depending on config:
- Plain text (the default for a long time) — the
secretcolumn inoauth_clientscontained the actual secret value - Hashed — via the
Passport::hashClientSecrets()opt-in, the column contained a bcrypt hash
The plain-text default was convenient: when you needed to remind yourself of a client's secret, you could literally look it up in the database. It was also a meaningful security risk — a database dump gave an attacker the keys to every OAuth integration on the platform.
In v13, this inverts. Hashed secrets are the default. The plain-text option still exists but is now the explicit opt-in, and the framework nudges hard toward leaving it off.
How the failure shows up
The symptoms after upgrading:
- Any first-party or third-party service that authenticates via
client_credentialsgrant starts getting400 Bad Requestwith"error": "invalid_client" - Personal access tokens (which use a special client under the hood) may also fail to issue
- The Passport UI's "create client" flow still works, but existing clients stop authenticating
- No obvious error in
storage/logs/laravel.logunless you've turned up OAuth server logging
The reason: Passport now compares the incoming secret against the stored column using Hash::check(). Your plain-text stored secret doesn't match its own hash (because it's not hashed), so authentication fails.
The migration path
There's no automatic migration for existing secrets. You have three options, in rough order of safety:
Option 1: Stay on plain-text temporarily, then migrate deliberately
In your AppServiceProvider::boot():
use Laravel\Passport\Passport;
public function boot(): void
{
// Explicitly opt out of the new hashed default while you migrate
Passport::hashClientSecrets(false);
}
This restores v12 behaviour. Your existing clients keep working. Then, on a schedule:
- Identify every active OAuth client
- Reach out to the owner of each one (your team, partner integrations, etc.)
- Rotate the secret —
php artisan passport:client --rotate {client-id}— noting the new plain-text value - Give the owner the new secret, wait for them to update their integration
- Once all clients are rotated, remove the
hashClientSecrets(false)line and let v13's default take over
This is the safest option because nothing breaks. It's also the slowest.
Option 2: Rotate all secrets in one maintenance window
If your integrations are all internal and you can coordinate a maintenance window:
- Put the service in maintenance mode
- Enable
Passport::hashClientSecrets()explicitly - Run a command that regenerates every client secret:
// In a one-off artisan command
use Laravel\Passport\Client;
use Illuminate\Support\Str;
$clients = Client::all();
foreach ($clients as $client) {
$newSecret = Str::random(40);
$client->secret = $newSecret; // will be hashed on save
$client->save();
$this->info("Client {$client->id}: {$newSecret}");
}
- Distribute the new secrets to the owners of each integration
- Exit maintenance mode
Risky if you have many integrations or the owners are external. Fast if you have three internal services all run by your team.
Option 3: Use the "last seen plain secret" trick for migration
Passport v13 includes a migration helper: a secret column can hold either the hash or the plain-text secret. On first successful authentication with a plain secret, Passport rewrites the column with the hash.
To enable this hybrid mode, make sure hashClientSecrets() is on (the default) and check your Passport version is v13.2 or later — the automatic rehash was added in a patch release, not the initial v13.0.
This means if you upgrade, leave hashing on, and don't force-rotate, the secrets get hashed progressively as clients authenticate. After a week or two of normal traffic, the vast majority of your clients will have migrated silently.
The caveat: any client that doesn't authenticate during that window stays as plain-text. You still need a cleanup pass eventually.
The storage cast caveat
If you were previously using the encrypted casting feature for secrets (Passport::storeClientSecretsEncrypted() in earlier versions), note that v13 changed the cast behaviour. Specifically:
// In Passport's Client model (v13+)
protected function casts(): array
{
return [
'secret' => 'hashed', // uses Laravel's built-in Hashed cast
// ...
];
}
The hashed cast is write-only — it hashes on set, but on read you get the hashed value, not the plain one. Which means you can't look up a secret after creation. If your team was doing that ("what's the secret for the reporting integration again?"), that workflow is dead. The new pattern is: generate the secret, show it once, and if it's lost, rotate.
This is the same pattern Laravel uses for API tokens in Sanctum and for password resets. It's a nuisance until you internalise it, then it's obviously correct.
The "invalid_client" error is misleading
One final trap worth flagging. When the secret comparison fails, the OAuth server returns invalid_client. This is the correct OAuth 2.0 error, but it's phrased as if the client itself is wrong — as if the client_id doesn't exist. That's what sent me down a rabbit hole initially.
If you see invalid_client after upgrading Passport:
- First check: does the client ID exist in
oauth_clients? (usually yes) - Second check: is
secretcolumn populated? (usually yes) - Third check: is the value in
secreta bcrypt hash starting with$2y$, or is it the plain text? (this is your answer)
If it's plain text, apply one of the three migration options above. If it's a hash and auth is still failing, the client has the wrong secret — rotate it.
The takeaway
The upgrade itself is easy — the config is straightforward, the change is well-motivated, and the framework's new default is genuinely more secure. The rough part is the migration of existing data, which has no automatic path.
Before upgrading Passport in production, count your OAuth clients, identify who owns each one, and pick a migration option. Don't just run composer update and hope. That's the class of mistake that ends with your team in a Slack call at 11pm explaining to a partner why their webhook integration stopped working.