Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

dannypas00's avatar

Why am I getting a 401 Unauthorized on Laravel Echo's authentication to a Private Channel?

I'm building a template project for myself, and I've got Reverb and Echo working as intended with regular channels, but when using private channels, the /broadcasting/auth route returns a 401 Unauthorized with {"message":"Unauthenticated."} as its body. I've been searching and debugging for a few hours so far, and none of the suggestions I found online completely fixed my issues. I've set the api and auth:sanctum middleware on the ->withBroadcasting() in the bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        api: __DIR__ . '/../routes/api.php',
        commands: __DIR__ . '/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware
            ->web(append: [
                HandleInertiaRequests::class,
                AddLinkHeadersForPreloadedAssets::class,
            ])
            ->trustProxies('*')
            ->validateCsrfTokens();
    })
    ->withBroadcasting('/../routes/channels.php', attributes: ['middleware' => ['api', 'auth:sanctum']])
    ->withExceptions()
    ->create();

My channels.php is empty, and the channel I'm trying to subscribe to is set using the BroadcastsEvents model trait on my User model:

class User extends Authenticatable
{
    use HasApiTokens;
    use HasFactory;
    use HasProfilePhoto;
    use Notifiable;
    use TwoFactorAuthenticatable;
    use BroadcastsEvents;

    ...

    public function broadcastOn($event): PrivateChannel
    {
        return new PrivateChannel('users.' . $this->id);
    }
}

My Echo is configured like so:

window.Echo = new Echo({
  broadcaster: 'reverb',
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST,
  wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
  wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
  forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
  enabledTransports: ['ws', 'wss'],
  encrypted: true,
  authorizer: (channel, options) => {
    console.log('a');
    return {
      authorize: (socketId, callback) => {
        console.log('b');
        axios
          .post(
            '/broadcasting/auth',
            {
              socket_id: socketId,
              channel_name: channel.name,
            },
            {
              withCredentials: true,
              headers: {
                Accept: 'application/json',
              },
            }
          )
          .then(response => {
            callback(false, response.data);
          })
          .catch(error => {
            callback(true, error);
          });
      },
    };
  },
});

I'm subscribing to the event in an onMounted from my frontend:

    onMounted(() => {
      if (user.value) {
        window.Echo.private(`users.${user.value.id}`).listen(
          '.UserUpdated',
          (event: { model: UserData }) => {
            console.log('Received user update from reverb!', event);
          }
        );
      }
    });

Furthermore I'm using the Jetstream starter kit without any impactful changes to the default configs. The user making the request is authenticated to the regular frontend routes just fine.

0 likes
1 reply
dannypas00's avatar

I discovered that my setup works as long as I add the statefulApi middleware. Also I had a typo where I didn't use base_path (or DIR) in the route path. Updating the bootstrap/app.php to this fixed the issues and I can now use private channels:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: base_path('routes/web.php'),
        api: base_path('routes/api.php'),
        commands: base_path('routes/console.php'),
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware
            ->web(append: [
                HandleInertiaRequests::class,
                AddLinkHeadersForPreloadedAssets::class,
            ])
            ->statefulApi()
            ->trustProxies('*')
            ->validateCsrfTokens();
    })
    ->withBroadcasting(base_path('routes/channels.php'), attributes: ['middleware' => ['api', 'auth:sanctum']])
    ->withExceptions()
    ->create();

Please or to participate in this conversation.