Session Not Persisting Across Controller Actions in Rails API

Hello everyone,

I’m facing an issue with session persistence in my Rails API application. I’ve set up a session in one controller action (nonce), but when I try to access it in another action (verify), it seems to be missing.

  1. Controller
class UsersController < ApplicationController
  before_action :log_response_headers

  def log_response_headers
    Rails.logger.info("Response Headers: #{response.headers}")
  end

  def nonce
    nonce = SecureRandom.hex(16)
    session[:nonce] = nonce
    render json: { nonce: nonce }
  end

  def verify
    nonce = session[:nonce]
    # ... rest of the code ...
  end
end

  1. Application Configuration
module HueBackend
  class Application < Rails::Application
    config.session_store :cookie_store, key: '_hue_session'
    config.session_options = {
      httponly: true,
      same_site: "None",
      secure: false
    }
    # ... rest of the configuration ...

    Rails.application.config.middleware.insert_before ActiveRecord::Migration::CheckPending, ActionDispatch::Cookies
    Rails.application.config.middleware.insert_before ActionDispatch::Cookies, Rails.application.config.session_store, Rails.application.config.session_options
  end
end

  1. Cors configuration
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:5173'
    resource '/api/*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true
  end
end

  1. Front-End fetch call
const response = await fetch('http://127.0.0.1:3000/api/nonce', {
  credentials: "include"
});

Checks Performed:

  1. Verified that credentials: "include" is set in frontend fetch calls.
  2. Checked the order of middlewares to ensure ActionDispatch::Cookies and the session store are loaded correctly.
  3. Inspected response headers for the Set-Cookie header after making a request to the nonce endpoint.
  4. Ensured the browser isn’t blocking third-party cookies.
  5. Checked server logs for any warnings or errors related to the session or cookies.

Despite these checks, the session doesn’t seem to persist across controller actions. Any insights or suggestions would be greatly appreciated!

I can share the github too if needed. Been having a headache on this for the past days…

Thank you!

1 Like

I don’t know exactly why this might be, but as a sanity check have you tried replacing 127.0.0.1 with localhost in your fetch request?

Code call! I’ve just tried but didn’t change anything.

By the way here are the logs in the terminal. As you can see Session ID in #nonce is: 2301f806d159f7b3e215f94f4c30765c.

But in #verify it’s empty.

Started GET "/api/nonce" for ::1 at 2023-08-31 20:55:26 +0200
Processing by Api::UsersController#nonce as */*
Response Headers: {"X-Frame-Options"=>"SAMEORIGIN", "X-XSS-Protection"=>"0", "X-Content-Type-Options"=>"nosniff", "X-Download-Options"=>"noopen", "X-Permitted-Cross-Domain-Policies"=>"none", "Referrer-Policy"=>"strict-origin-when-cross-origin"}
inside the nonce function
Session ID in nonce: 2301f806d159f7b3e215f94f4c30765c
NONCE in session nonce 2: d741deacafd1af3868b1214966341f16
NONCE in session nonce 3: d741deacafd1af3868b1214966341f16
Completed 200 OK in 2ms (Views: 0.2ms | ActiveRecord: 0.0ms | Allocations: 1137)


Started POST "/api/verify" for ::1 at 2023-08-31 20:55:31 +0200
Processing by Api::UsersController#verify as */*
  Parameters: {"message"=>{"domain"=>"localhost:5173", "address"=>"0x822BD6Dbd9Dd540c06512a858C2b3B9550581744", "statement"=>"Sign in with Ethereum to the app.", "uri"=>"http://localhost:5173", "version"=>"1", "chainId"=>280, "nonce"=>"d741deacafd1af3868b1214966341f16", "issuedAt"=>"2023-08-31T18:55:27.996Z"}, "signature"=>"0xb965568ec7d75c817429d9a5e7267adf030f57e4883ebe4f7ad6a0a01c5b06e97b8896a6033b9a8df037837418439c96702342034bb6a5aaeea7eaf2a685c8151c", "user"=>{}}
Response Headers: {"X-Frame-Options"=>"SAMEORIGIN", "X-XSS-Protection"=>"0", "X-Content-Type-Options"=>"nosniff", "X-Download-Options"=>"noopen", "X-Permitted-Cross-Domain-Policies"=>"none", "Referrer-Policy"=>"strict-origin-when-cross-origin"}
Session ID in verify:
Entire session data:
NONCE in verify:
MESSAGE in verify: {"domain"=>"localhost:5173", "address"=>"0x822BD6Dbd9Dd540c06512a858C2b3B9550581744", "statement"=>"Sign in with Ethereum to the app.", "uri"=>"http://localhost:5173", "version"=>"1", "chainId"=>280, "nonce"=>"d741deacafd1af3868b1214966341f16", "issuedAt"=>"2023-08-31T18:55:27.996Z"}
SIGNATURE in verify: 0xb965568ec7d75c817429d9a5e7267adf030f57e4883ebe4f7ad6a0a01c5b06e97b8896a6033b9a8df037837418439c96702342034bb6a5aaeea7eaf2a685c8151c
ADDRESS in verify: 0x822BD6Dbd9Dd540c06512a858C2b3B9550581744
msg hash in verify signature: �l��Oӓ�n�� >�9�gZ�TĢ2K�"
public key in verify signature: 04f61b6e8bc3e99978b43e82277ac34d22f42e223a30fcc6f47a3443ea7080b9bb1ca2403a088618fc5bfb5520437a215447207329dc507f981f5ffb6c27c8a637
 recovered address in verify signature: 0x25f54C426b2A5CcdA9Cd523DA3dA3b3aD96864c6
address in verify signature: 0x822BD6Dbd9Dd540c06512a858C2b3B9550581744
Completed 401 Unauthorized in 14ms (Views: 0.2ms | ActiveRecord: 57.1ms | Allocations: 1990)

Yeah, it seems that your problem is in convincing the browser to set this cookie.

The way it works is that Rails just tells your browser “please store this cookie” when you go to /nonce, then browser does a whole bunch of verifications before it agrees to store it. Then when you go to /verify — the fetch will ship this cookie if it was stored, but it seems like it wasn’t. So it seems your browser doesn’t like something about the way you ask it to store the cookie on /nonce step. (I assume the session cookie is never stored, right?).

If that’s all true, you have to be very careful about every little thing. Follow list like this, and make sure you don’t have any discrepancies in domain name (localhost vs. 127.0.0.1 is not good).

Basically, this doesn’t seem to be a Rails problem, but rather adhering to the browser security problem.

Gotcha! Indeed. When I inspect and open the “Network” tab, this is what I get on the fetch /nonce in the response headers.

Access-Control-Allow-Credentials:
true
Access-Control-Allow-Methods:
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
Access-Control-Allow-Origin:
http://localhost:5173
Access-Control-Expose-Headers:
Access-Control-Max-Age:
7200
Cache-Control:
max-age=0, private, must-revalidate
Content-Type:
application/json; charset=utf-8
Etag:
W/"384dd6c831255dc25a47a6d0016af1aa"
Referrer-Policy:
strict-origin-when-cross-origin
Server-Timing:
start_processing.action_controller;dur=0.17, process_action.action_controller;dur=12.06
Transfer-Encoding:
chunked
Vary:
Accept, Origin
X-Content-Type-Options:
nosniff
X-Download-Options:
noopen
X-Frame-Options:
SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
none
X-Request-Id:
94853176-919d-4ec8-8a2c-af55cd74f8ae
X-Runtime:
0.024786
X-Xss-Protection:
0

And in the Cookie tab it’s not stored either.

I’ve also tried to setup a new app to see if I had the same problem and it happened there too.

Weird, it looks like rails doesn’t even send Set-Cookie header. Maybe it does have to do with the way Rails creates sessions, or a session misconfiguration.

This was my supposition. The fetch calls are working. And it’s like Rails isn’t taking into account my options.

  class Application < Rails::Application
    config.session_store :cookie_store, key: '_hue_session'
    config.session_options = {
      httponly: true,
      same_site: "None",
      secure: false
    }
    # ... rest of the configuration ...

    Rails.application.config.middleware.insert_before ActiveRecord::Migration::CheckPending, ActionDispatch::Cookies
    Rails.application.config.middleware.insert_before ActionDispatch::Cookies, Rails.application.config.session_store, Rails.application.config.session_options

So I tried something different in the application.rb code and removed the order for the middleware

module HueBackend
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.session_store :cookie_store, key: '_hue_session', domain: :all
    puts("Loading cookies session store KEY")



    puts("Loading cookies session store options")
    config.session_options = {
      httponly: true,
      same_site: "None",
      secure: false
    }

    # ... rest of the configuration ...

    config.load_defaults 7.0
    config.api_only = true

#     Rails.application.config.middleware.insert_before ActiveRecord::Migration::CheckPending, ActionDispatch::Cookies
# Rails.application.config.middleware.insert_before ActionDispatch::Cookies, Rails.application.config.session_store, Rails.application.config.session_options

config.middleware.use ActionDispatch::Cookies
config.middleware.use config.session_store, config.session_options

  end
end

This is what I get in the network tab:

Access-Control-Allow-Credentials:
true

Access-Control-Allow-Methods:
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
Access-Control-Allow-Origin:
http://localhost:5173
Access-Control-Expose-Headers:
Access-Control-Max-Age:
7200
Cache-Control:
max-age=0, private, must-revalidate
Content-Type:
application/json; charset=utf-8
Etag:
W/"e3e5176fcaec90c8a7c31faac18d3b0f"
Referrer-Policy:
strict-origin-when-cross-origin
Server-Timing:
start_processing.action_controller;dur=0.16, process_action.action_controller;dur=8.22
Set-Cookie:
_session_id=v9vZB6aK6SRDhZH25R1TGcW49HPt4%2B0agXGe5ozNn5TbrakY2rWbPKx5Np%2FpvdwdpqImunqIxKImEmO9Bk00Zg8w38GK%2FLHbNKeVOOP92GVi7CGeUTUyzQAuz3s7B0dedAUlYjLL%2BKo89%2Fj%2FUYjQ%2BMvbaODhcQLrlIJp7az1rxzjpBNN100r1lSRjD7OPz37piJBI8HUXAskm8KbWlwG0SINN%2F6cmvf5BY95G8SQr%2BvGVighoLLBE1vFhA%3D%3D--MEiNRuGxTwlv9oPa--DYMOL3rRaxGapcnFVtc0yw%3D%3D; path=/; HttpOnly; SameSite=None
Transfer-Encoding:
chunked
Vary:
Accept, Origin
X-Content-Type-Options:
nosniff
X-Download-Options:
noopen
X-Frame-Options:
SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
none
X-Request-Id:
c153d4ce-679e-476a-87bb-a8b96633dbf6
X-Runtime:
0.019885
X-Xss-Protection:
0

And in the Response Cookie I have:

_session_id	v9vZB6aK6SRDhZH25R1TGcW49HPt4%2B0agXGe5ozNn5TbrakY2rWbPKx5Np%2FpvdwdpqImunqIxKImEmO9Bk00Zg8w38GK%2FLHbNKeVOOP92GVi7CGeUTUyzQAuz3s7B0dedAUlYjLL%2BKo89%2Fj%2FUYjQ%2BMvbaODhcQLrlIJp7az1rxzjpBNN100r1lSRjD7OPz37piJBI8HUXAskm8KbWlwG0SINN%2F6cmvf5BY95G8SQr%2BvGVighoLLBE1vFhA%3D%3D--MEiNRuGxTwlv9oPa--DYMOL3rRaxGapcnFVtc0yw%3D%3D	localhost	/	Session	333	✓		
None		Medium

What’s weird is the default name session_id when I clearly changed it in the application.rb. So the logs in the terminal are exactly the same. No session ID in the /verify.

Any idea what could be the cause?

Hey Antoine,

I feel your frustration; session management can be quite the labyrinth in Rails APIs. From your code and checks, it seems like you’ve covered the essentials. However, the devil often resides in details. Here are some ideas that might help:

  1. Same-Site Attribute: You mentioned setting “None” for the ‘same_site’ attribute. Ensure it’s necessary for your case; it’s often used in cross-origin scenarios, especially if you’re dealing with cross platform software entwicklung.

  2. Cookie Domain: Double-check the domain attribute in your session cookie. It should match the domain from which your API is being accessed. It’s often a source of session issues.

  3. HTTPS: If your site is served over HTTPS, ensure the secure attribute in the session configuration is set to true.

  4. Rack::Cors Configuration: Your CORS config appears fine, but ensure that it’s not unintentionally altering your cookies.

  5. Third-Party Cookies: Even if your browser isn’t explicitly blocking third-party cookies, it might be treating your API as a third party. You can test this theory by changing your frontend URL to match your backend domain.

  6. Timezone Discrepancy: Check if your server and client have a significant time zone difference. Sometimes, this can affect cookie expiration.

  7. Logging: Add more detailed logging in both the ‘nonce’ and ‘verify’ actions to trace what’s happening with the session. It might reveal where it’s going astray.

I hope one of these suggestions helps you untangle this conundrum. If not, sharing your GitHub might allow for more in-depth assistance from the community.

1 Like