Skip to main content

Documentation Index

Fetch the complete documentation index at: https://kawax.biz/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Bluesky’s OAuth is based on the AT Protocol and differs significantly from standard Socialite providers such as GitHub or Google.
Bluesky OAuth is fundamentally different from other Socialite providers. It uses DPoP (Demonstrated Proof of Possession) and PAR (Pushed Authorization Requests) endpoints. No client_secret is needed — you use a private key instead.

Differences from standard OAuth

ItemStandard SocialiteBluesky Socialite
Auth methodOAuth 2.0AT Protocol OAuth (DPoP)
Client identityclient_id + client_secretPrivate key + client-metadata.json URL
client_idRegistered fixed stringURL of client-metadata.json
User identifierEmail address etc.DID (did:plc:...) or handle
Login hintNot requiredHandle / DID / PDS URL

Authentication flow

Installation and configuration

Create a private key

Generate a private key. No Bluesky account registration is required.
php artisan bluesky:new-private-key
Please set this private key in .env

BLUESKY_OAUTH_PRIVATE_KEY="...url-safe base64 encoded key..."
Copy the output into your .env file.
BLUESKY_OAUTH_PRIVATE_KEY="..."
For Bluesky, you don’t need to register a client_id or client_secret. Setting the private key is the only required step.

Default OAuth scopes

The package is configured with default OAuth scopes to support three main use cases:
  1. Socialite Loginatproto, account:email, and include:app.bsky.authViewAll enable user authentication and email access
  2. Postinginclude:app.bsky.authCreatePosts and blob:*/* allow creating posts and uploading images/videos
  3. DM Notificationsrpc:chat.bsky.convo.sendMessage and rpc:chat.bsky.convo.getConvoForMembers enable sending direct messages for notifications
You can customize the scopes by setting the BLUESKY_OAUTH_SCOPE environment variable:
BLUESKY_OAUTH_SCOPE="atproto account:email include:app.bsky.authViewAll"
For more information about available scopes, see the AT Protocol Permission Requests documentation.

Local development

By default, http://localhost and http://127.0.0.1:8000/ are preconfigured, so no additional .env settings are needed for local development.
# Only set if you use a different port
BLUESKY_CLIENT_ID=
BLUESKY_REDIRECT=http://127.0.0.1:8080/

Production

If the route named bluesky.oauth.redirect exists, no .env configuration is needed. Set it only if you have changed the default route name.
BLUESKY_REDIRECT=/bluesky/callback

Route setup

Use bluesky.oauth.redirect as the callback route name — the package uses this name internally.
// routes/web.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SocialiteController;

Route::get('login', [SocialiteController::class, 'login'])->name('login');
Route::match(['get', 'post'], 'redirect', [SocialiteController::class, 'redirect']);
Route::get('bluesky/callback', [SocialiteController::class, 'callback'])
     ->name('bluesky.oauth.redirect');

Handling callbacks in local development

During local development, the callback URL returned by Bluesky is fixed to http://127.0.0.1:8000/. Redirect it at the route level.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/', function (Request $request) {
    if (app()->isLocal() && $request->has('iss')) {
        return to_route('bluesky.oauth.redirect', $request->query());
    }

    // ...
});

Controller implementation

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
use Revolution\Bluesky\Session\OAuthSession;

class SocialiteController extends Controller
{
    public function login(Request $request)
    {
        // A form page to submit a login hint
        return view('login');
    }

    public function redirect(Request $request)
    {
        // You can pass a handle, DID, or PDS URL as a login hint (optional)
        $hint = $request->input('login_hint');
        $request->session()->put('hint', $hint);

        return Socialite::driver('bluesky')
                        ->hint($hint)
                        ->redirect();
    }

    public function callback(Request $request)
    {
        if ($request->missing('code')) {
            // For debugging during development. Replace with proper error handling in production.
            dd($request);
        }

        $hint = $request->session()->pull('hint');

        /** @var \Laravel\Socialite\Two\User $user */
        $user = Socialite::driver('bluesky')
                         ->hint($hint)
                         ->user();

        /** @var OAuthSession $session */
        $session = $user->session;

        // Save the OAuthSession into the Laravel session
        $request->session()->put('bluesky_session', $session->toArray());

        $loginUser = User::updateOrCreate([
            'did' => $session->did(),
        ], [
            'iss'           => $session->issuer(),
            'handle'        => $session->handle(),
            'name'          => $session->displayName(),
            'avatar'        => $session->avatar(),
            'access_token'  => $session->token(),
            'refresh_token' => $session->refresh(),
        ]);

        auth()->login($loginUser, true);

        return to_route('bluesky.dashboard');
    }
}

User information (OAuthSession)

Key methods available on the OAuthSession retrieved from $user->session.
MethodDescriptionExample
did()Bluesky DIDdid:plc:xxxxxx
handle()Bluesky handlealice.bsky.social
displayName()Display nameAlice
avatar()Avatar URLhttps://...
issuer()PDS URLhttps://bsky.social
token()Access token
refresh()Refresh token
Use toArray() to inspect all properties.
/** @var OAuthSession $session */
dump($session->toArray());

Database setup

Add Bluesky-specific columns to the users table. The DID is the unique identifier for a Bluesky user.
// database/migrations/xxxx_add_bluesky_columns_to_users_table.php

Schema::table('users', function (Blueprint $table) {
    $table->string('did')->nullable()->unique();
    $table->string('iss')->nullable();
    $table->string('handle')->nullable();
    $table->string('avatar')->nullable();
    $table->text('access_token')->nullable();
    $table->text('refresh_token')->nullable();
});

Reusing the OAuthSession

Use the OAuthSession stored in the session to call APIs.
use Revolution\Bluesky\Facades\Bluesky;
use Revolution\Bluesky\Session\OAuthSession;

$session = OAuthSession::create(session('bluesky_session'));

$timeline = Bluesky::withToken($session)->getTimeline();
In Jobs or Console commands where the Laravel session is unavailable, build an OAuthSession from the database.
use Revolution\Bluesky\Facades\Bluesky;
use Revolution\Bluesky\Session\OAuthSession;

$session = OAuthSession::create([
    'did'           => $user->did,
    'refresh_token' => $user->refresh_token,
    // For accounts not on bsky.social, also specify iss
    // 'iss'        => $user->iss,
]);

$timeline = Bluesky::withToken($session)
                   ->refreshSession()
                   ->getTimeline();

Automatic token refresh

Refresh tokens can only be used once, so you must save the updated token after each refresh. Use the OAuthSessionUpdated event.
php artisan make:listener OAuthSessionUpdatedListener
namespace App\Listeners;

use App\Models\User;
use Revolution\Bluesky\Events\OAuthSessionUpdated;

class OAuthSessionUpdatedListener
{
    public function handle(OAuthSessionUpdated $event): void
    {
        if (empty($event->session->did())) {
            return;
        }

        session()->put('bluesky_session', $event->session->toArray());

        User::firstWhere('did', $event->session->did())
            ->fill([
                'iss'           => $event->session->issuer(),
                'handle'        => $event->session->handle(),
                'name'          => $event->session->displayName(),
                'avatar'        => $event->session->avatar(),
                'access_token'  => $event->session->token(),
                'refresh_token' => $event->session->refresh(),
            ])->save();
    }
}
The OAuthSessionRefreshing event fires when a refresh starts. Clear the refresh token from the database here, since it becomes invalid at this point.
use Revolution\Bluesky\Events\OAuthSessionRefreshing;

public function handle(OAuthSessionRefreshing $event): void
{
    if (empty($event->session->did())) {
        return;
    }

    User::firstWhere('did', $event->session->did())
        ->fill(['refresh_token' => ''])
        ->save();
}

WithBluesky trait

Add the WithBluesky trait to your User model and implement tokenForBluesky() to get an authenticated Bluesky client via $user->bluesky().
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Revolution\Bluesky\Session\OAuthSession;
use Revolution\Bluesky\Traits\WithBluesky;

class User extends Authenticatable
{
    use WithBluesky;

    protected function tokenForBluesky(): OAuthSession
    {
        return OAuthSession::create([
            'did'           => $this->did,
            'refresh_token' => $this->refresh_token,
            'iss'           => $this->iss,
        ]);
    }
}
$profile = auth()->user()
                 ->bluesky()
                 ->refreshSession()
                 ->getProfile();

Customizing client-metadata

The package automatically registers bluesky.oauth.client-metadata and bluesky.oauth.jwks routes. Changes are rarely needed, but you can customize them with OAuthConfig.
// AppServiceProvider

use Revolution\Bluesky\Socialite\OAuthConfig;

public function boot(): void
{
    OAuthConfig::clientMetadataUsing(function () {
        return collect(config('bluesky.oauth.metadata'))
            ->merge([
                'client_id'     => route('bluesky.oauth.client-metadata'),
                'jwks_uri'      => route('bluesky.oauth.jwks'),
                'redirect_uris' => [url('bluesky/callback')],
            ])
            ->reject(fn ($item) => is_null($item))
            ->toArray();
    });
}

Unauthenticated behavior

If OAuthSession is null or has no refresh token, an Unauthenticated exception is thrown and the user is redirected to the login route.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->redirectGuestsTo('/bluesky/login');
})
Last modified on April 29, 2026