How to use Phoenix `socket` on localhost with React Frontend?

Hello, I am trying to build a Backend system in Phoenix, this is mostly APIs but needs Websockets for a chat interface.

I researched a bit with Claude, and found out that Phoenix sockets can be a great choice for this with Channels, so I create a setup as details below.

I have two tables

chat_sessions
chat_messages

When the user lands on /api/v1/chat/sessions, I check for a few things and then create a record in this table, and I use put_session to set the id of this table in a cookie and send it back to the client.

Now I created a socket endpoint in the endpoint file similar to /live just under /live

socket "/api/chat", ProfiWeb.UserSocket,
    websocket: [connect_info: [session: @session_options]],
    longpoll: false

Inside the UserSocket I added

def connect(params, socket, connect_info) do
    Logger.info("WebSocket connection attempt with params: #{inspect(params)}")
    Logger.info("Connect info session: #{inspect(connect_info[:session])}")
   
    with {:ok, session_id} <- extract_session_id(connect_info),
....
...


defp extract_session_id(connect_info) do
    case connect_info[:session] do
      %{"chat_session_id" => id} when is_binary(id) ->
        Logger.info("Found chat_session_id in session: #{id}")
        {:ok, id}

      _ ->
        Logger.info("No session found, checking params for session_id")
        {:error, :no_session_id}
    end
  end

Backend is running on http://localhost:4000 and FE is on http://localhost:4321

I was hoping this setup would be enough and I will get the cookie since inside session options, same_site is set to “Lax”

 @session_options [
    store: :cookie,
    key: "_profi_key",
    signing_salt: "4yRHL+9g",
    same_site: "Lax"
  ]

But when I check the request headers in browser, I don’t see the cookie, googling and going back and forth with Claude, finally landed on creating a reverse proxy on localhost:3000 and proxying both through that.

When I do that, browsers start sending the cookie, but the server still doesn’t get it.

I fought this problem a lot, with Claude a lot but nothing worked, added a ton of logs but nothing.

Does anyone know how I can do this ?

Thank you for your help, much appreciated :folded_hands:

Phoenix Forum > Questions / Help

EDIT: If I missed any details, please let me know, I will update the post with those details.

The cookie is not available for channels by default due to concerns around CSWSH vulnerabilities.

The session connect_info option has some further requirements. Are you sure you handled those:

In order to validate the session, the “_csrf_token” must be given as request parameter when connecting the socket with the value of URI.encode_www_form(Plug.CSRFProtection.get_csrf_token()).

Thank you for sharing that, I read it, but not sure what I missed, is their an example I can maybe look at ?

Sorry if I missed something obvious.

I tried adding token: Plug.CSRFProtection.get_csrf_token() to the /api/v1/chat/sessions response, and then

const phoenixSocket = new Socket(
  `${import.meta.env.PUBLIC_BACKEND_BASE_URL}/api/chat`,
   {
     params: {
       _csrf_token: data.data.token,
     },
   },
);

I even added check_origin: false to

socket "/api/chat", ProfiWeb.UserSocket,
    websocket: [
      connect_info: [session: @session_options],
      check_origin: false
    ],
    longpoll: false

Still doesn’t work.

Proxy (Caddyfile)

http://localhost:3000 {
    # Proxy /api requests to localhost:4000
    handle /api* {
        reverse_proxy localhost:4000
    }

    # Proxy all other requests to localhost:4321
    handle {
        reverse_proxy localhost:4321
    }
}

@josevalim can you help with this ?

First, you’ve chosen one of the most difficult ways to architect a website on the modern web, owing mostly to a gauntlet of security measures you will have to properly configure and work with. What is your app, and does it require a completely separate front-end? Does it even require React? 90% of SPAs are over-engineered, fragile, and worse off for being SPAs instead of progressively enhanced classic web sites, mainly due to an over-indulgence in following trends and cargo culting.

Are you going to host it on two separate domains, as is implied by running two dev servers. If you instead configure Phoenix to serve all of your static assets, then you don’t need to configure CORS, or deal with cookies being set for two different domains (though sharing across subdomains is relatively easy.) You can still share a domain with two different services behind it, but then you’ll need an HTTP aware proxy/load balancer to know that “/api” requests go to the Phoenix cluster, and “/app” requests go to the front-end nodejs or whatever cluster. So my main question is, what are you building, and does it really require this complexity? None of it is impossible, but if you’re new to these things (assumed by use of LLMs for research, and generally for the kinds of questions you’ve asked here and on Discord, apologies if you’re not), then you might want to reevaluate trying to build something so complex without some experience with simpler setups.

Second, Phoenix.Socket will likely never allow you to access arbitrary headers or cookies as LostKobrakai said, due to Cross-Site Websocket Hijacking. This is a serious problem, and I agree with Phoenix’s hard-line stance to basically make it close to impossible to fall into this trap. There is no way around this other than implementing your own version of Phoenix Channels.

I’m honestly not sure why using a local proxy made the browser start sending cookies for the socket connection, but I suspect it was coincidental. Websocket() will send any set cookies, depending on the cookie setting. However, fetch() will not allow the server to set cookies. So if your FE is truly separate and you’re “landing on /api/v1/chat/session” with fetch(), the only way the session cookie could have been sent was if you manually made a request to your BE on localhost from your browser at some point, which set the cookie. However, if you do deploy this to two separate domains, then it’s going to be very difficult to get the session cookie from one domain to the other, anyways.

If you really want to have a separate FE and BE, on separate domains, then the simplest solution is to treat the FE like it’s a completely different application running in a completely environment. Don’t use session data or cookies at all, and instead follow the guide that Kapsy in Discord already sent you.

The only difference, is that instead of setting the token in the assigns and in the JS of the page (which works if phoenix serves your FE), you will need to make a fetch() request from the FE to the BE with authentication information to your API, which will return a token for the JS in the FE to use when connecting to the Channel.

BTW, none of this is the fault of Phoenix or Elixir… this is all due to the security requirements of the modern web, which has had to add a lot of hoops to jump through due to bad actors exploiting vulnerabilities like CSWSH and CSRF to hack web sites. That and your desire to create one of the most difficult stacks to build a web site with, requiring knowledge and experience across a wide swatch of technologies, protocols, and security standards to get right.

1 Like

@vipulbhj GitHub - LostKobrakai/channel_session this got a working example. I guess you didn’t pull in the connect_info in the connect callback of the socket. Stuff one doesn’t really think of when not having a concrete codebase in front of their nose.

@LostKobrakai Thank you so much for taking the time and creating this repo. I really really appreciate it.

Actually I have connect_info in the callback, that is the thing thats been the issue.

I have been wanting to update the post title, but I don’t have access to that. The issue is I am always getting session as nil.

While this has been a struggle, I have been exploring the Phoenix codebase with Claude, and last nigh I discovered the error_handler: {__MODULE__, :websocket_error_handler, []}

Because WebSocket connections bypass the normal endpoint file flow, I was finally able to make sure the tokens were reaching Phoenix

  def websocket_error_handler(conn, reason) do
    require Logger

    conn = Plug.Conn.fetch_cookies(conn)

    # Try to manually decrypt the session cookie
    session_cookie = conn.cookies["_profi_key"]
    session_result = try_decrypt_session(session_cookie)

    Logger.error("""
    ============ WebSocket Session Debug ============
    Cookie Value: #{String.slice(session_cookie, 0, 50)}...
    Manual Decryption Result: #{inspect(session_result)}

    Endpoint Config:
    Secret Key Base Present?: #{conn.secret_key_base != nil}

    Session Options: #{inspect(@session_options)}
    ================================================
    """)

    Plug.Conn.send_resp(conn, 403, "Debug complete")
  end
  def connect(params, socket, connect_info) do
    {:error, :debug_connection_state}
  end

I have got it down to the fact that it’s 100% a decryption error, but not sure what exactly, will try more and report back.

[debug] Sent 403 in 2ms
[debug] GET /api/chat/websocket
[info] REFUSED CONNECTION TO ProfiWeb.UserSocket in 18µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "AFl6HigDNUBXBShPMwE9Oj4oZR4rLB9cC68RpNr3ofqbESPtsF5IJkG0", "vsn" => "2.0.0"}
[error] ============ WebSocket Session Debug ============
Cookie Value: SFMyNTY.g3QAAAACbQAAAAtfY3NyZl90b2tlbm0AAAA4QUZsNk...
Manual Decryption Result: {:error, %KeyError{key: :rotating_options, term: %{store: :cookie, key: "_profi_key", signing_salt: "4yRHL+9g", secret_key_base: "NTXbWzk9eVD2Vh2VxXTfa5ZizGpd7Fma1EV2FeI5vHU0iOcQmbtRG8k3OkJ0Unmj", same_site: "Lax"}, message: nil}}

Endpoint Config:
Secret Key Base Present?: true

Session Options: [store: :cookie, key: "_profi_key", signing_salt: "4yRHL+9g", same_site: "Lax"]
================================================

[debug] Sent 403 in 992µs

Since, Phoenix.Socket.Transport.init_session(session_config) is private, I am emulating it, to see what is the exact issue.

I am really sorry, I didn’t have enough access for me to change the title of this post, once I had created it.

When I created this topic, I hadn’t fully understood what I was trying to do, what was the exact problem, or even what I was doing. But in the process of exploration, I learnt a lot and finally fixed my issue.

I want to reply to your message earlier but forgot, sorry about that as well.

What is your app, and does it require a completely separate front-end?

I was playing with that idea of, how do those chatbots on websites work. You know that annoying popup which makes this weird sound, I though those are external services that work on any platform, so how would I build one, if I had to.

Nothing to do with React, SPA, or anything else. I just care about their existing a web browser.

Are you going to host it on two separate domains, as is implied by running two dev servers.

Yeah, I am thinking, I provide a documentation page to people who want to connect with my service, which shows how to setup a proxy, so they can forward cookies and traffic to my service correct and we can establish a websocket connection, but also being able to track chat sessions.

fetch() will not allow the server to set cookies.
Actually it does, you just need to set credentials: include Using the Fetch API - Web APIs | MDN

BTW, none of this is the fault of Phoenix or Elixir… this is all due to the security requirements of the modern web, which has had to add a lot of hoops to jump through due to bad actors exploiting vulnerabilities like CSWSH and CSRF to hack web sites.

Absolutely agreed, I actually only figured it our, when I cloned Phoenix’s source locally and started tracking out things, documenting stuff, and finally understood the whole transporter pipeline, which, once I understood what was going on, made figuring out what was going on super easy.

The issue with what I was doing, was non of what I had suspected. It was just one of those things where, if you have not been burnt by it before, you just don’t pay enough attention about it.

My issue was, since I was setting cookies in the browser though an API call, in router.ex, api pipeline was

pipeline :api do
    plug :accepts, ["json"]

    plug Guardian.Plug.Pipeline,
      module: MyApp.Guardian,
      error_handler: MyApp.AuthErrorHandler

    plug Guardian.Plug.VerifyHeader, scheme: "Bearer"
    plug Guardian.Plug.LoadResource, allow_blank: true
  end

And I was using put_session to put cookies I wanted in connect_info to the browser, but it would always turn up blank, so after tracking through phoenix’s source code (which was extremely approachable btw, amazing work to all core devs), I realised a key piece of detail.

If you see pipeline :browser do in the router.ex file, you will find plug :protect_from_forgery, this one line does such a cool thing, if you see the implementation

def protect_from_forgery(conn, opts \\ []) do
    Plug.CSRFProtection.call(conn, Plug.CSRFProtection.init(opts))
  end

We are calling CSRFProtection.call, which if you check the implementation of def call(conn, {session_key, mode, allow_hosts}) do plug/lib/plug/csrf_protection.ex at 065976d85f3d079d4f22dde7897c2e7f67c596e0 · elixir-plug/plug · GitHub

You see the missing magic, with plug :protect_from_forgery, we get for free unmasked_token = Plug.CSRFProtection.dump_state(), aka the cookie automatically include put_session(:_csrf_token, unmasked_token)`, which is what is used to validate csrf_tokens.

Once, I realised what was wrong, the fix was obvious, and once I had done that I realised I had see this details already, phoenix/test/phoenix/socket/transport_test.exs at 1f969b5ed7584b61c8c1934108d865ed0ea65735 · phoenixframework/phoenix · GitHub this test is doing exactly this, but I hadn’t realised so, till I went on this journey.

The systems has been working great for me since, I learnt a lot about Phoenix in the process, and more about figuring thing out :smiley: