While implementing HMAC-based SSO between two Rails applications, I ran into two unexpected bugs. Both stemmed from how Turbo Drive and ERB handle things under the hood. The error message was identical in both cases — “state mismatch” — but the root causes were completely different. Fixing the first bug left the second one still lurking, which made the debugging process more frustrating than it needed to be.


Implementation Overview

Structure

Two independent Rails apps are connected via SSO:

  • IdP (Identity Provider): The Rails app responsible for user authentication. It handles OTP login and issues One-Time Tokens.
  • SP (Service Provider): The Rails app that logs users in using the token issued by the IdP. It does not handle user credentials directly and delegates trust to the IdP.

This architecture is a common pattern for implementing shared authentication in a small multi-app environment. It is simpler than OAuth 2.0, but still requires CSRF protection and token verification.

Flow

User clicks "SSO Login" button on SP
  → SP: generates state, stores it in session, redirects to IdP /authorize
  → IdP: verifies login, issues a One-Time Token
  → IdP: displays authorize_complete page (auto-redirects to SP callback after 2 seconds)
  → SP callback: validates state + validates token → login complete

The state parameter is a randomly generated UUID-like string. The SP generates it and stores it in the session before redirecting to the IdP. When the callback returns, the SP compares the state from the session with the state in the query parameters. If they match, the flow is considered legitimate. If they do not match, the request is rejected with a “state mismatch” error.

Core Security Elements

  • CSRF protection: The state generated by the SP is stored in the session and verified during the callback. Even if a third party calls the callback URL arbitrarily, the request is blocked because there is no matching state in the session.
  • HMAC signature: The SP sends a signed request to the IdP’s /verify endpoint to validate the token. This prevents token forgery and man-in-the-middle attacks.
  • One-Time Token: The token is invalidated after a single use, preventing replay attacks.

Bug 1: “state mismatch” — Turbo Drive Prefetch Overwrites the Session

Symptoms

Clicking the “SSO Login” button on the SP would successfully navigate to the IdP’s authentication completion page, but the SP callback always threw a state mismatch error. The bug only reproduced on the Render deployment environment, not locally, which made it harder to track down.

Checking the Render server logs, the /auth/sso/initiate endpoint was being hit twice within 0.77 seconds:

05:09:23.205 - [req_A] Initiating SSO ... state=wvOVbkLL...
05:09:23.978 - [req_B] Initiating SSO ... state=fhnVtQr2...
05:09:26.748 - [callback] state mismatch

Each request generated a different state. The user clicked the button once, but the server received two requests.

Cause

This was caused by Turbo Drive’s prefetch behavior.

Turbo Drive is a core component of Hotwire that makes page transitions feel like a single-page application. As part of its performance optimization, it prefetches links before the user actually clicks them — on hover, or in some cases when a page loads and links are visible in the viewport.

The SSO initiation link (/auth/sso/initiate) became a prefetch target.

[Page load] → Turbo prefetches /auth/sso/initiate
               → Server: generates state_A, stores in session
               → 302 redirect → IdP (response blocked by CORS, but session cookie is set)

[User click] → Actual /auth/sso/initiate request
               → Server: generates state_B, overwrites session
               → 302 redirect → IdP with state_B in the redirect URL

[IdP callback] → Returns to SP with state_B in query params
                 → But what state is in the session?

This creates a race condition. The prefetch request writes state_A to the session. The real click overwrites it with state_B. The IdP may carry back either value depending on timing, and the session state depends on which request finished last.

The deeper problem is that both requests arrive with the same browser session cookie, so the server has no way to distinguish a legitimate click from a Turbo prefetch. The User-Agent is identical, the cookies are identical. While some browsers attach a Sec-Purpose: prefetch header, relying on this is fragile across different browsers and Turbo Drive versions.

The reason this did not reproduce locally: local page loads are fast and the conditions for triggering prefetch were not met. On Render, the additional network latency caused Turbo’s prefetch to fire.

Fix

Add data-turbo="false" to the SSO button link in the SP:

<%= link_to sso_initiate_path, data: { turbo: false } do %>
  Sign in with SSO
<% end %>

data-turbo="false" tells Turbo Drive to completely ignore this link. No prefetching, no caching, no interception — the browser handles it as a normal full-page navigation.

This is both the simplest and the most semantically correct fix. Endpoints like SSO initiation that mutate server-side session state should not be subject to Turbo Drive’s optimizations. They are not idempotent: every invocation creates a new state, so any premature invocation causes side effects.

If you want a more targeted fix that preserves Turbo Drive’s other features, data-turbo-prefetch="false" disables only the prefetch behavior. However, since SSO initiation always triggers a full-page redirect anyway, data-turbo="false" expresses the intent more clearly.


Bug 2: &amp;state= — Double Encoding from the ERB j Helper

Symptoms

Even after fixing Bug 1, state mismatch errors continued. This time the server logs showed only a single request, yet it still failed. Looking more carefully at the logged URL, the callback URL itself was malformed:

GET /auth/sso/callback?token=abc123&amp;state=xyz789

Instead of &state=, the server was receiving &amp;state= literally. Rails’ query parameter parsing treats &amp; as an ordinary string rather than a parameter separator. As a result, params[:state] is nil, and the value ends up under the key "amp;state" instead.

Cause

The problem was in the automatic redirect JavaScript on the IdP’s authorize_complete page:

<script>
  setTimeout(function() {
    window.location.href = "<%= j @callback_url %>";
  }, 2000);
</script>

@callback_url is a plain Ruby string constructed in the controller:

# IdP controller
@callback_url = "https://sp.example.com/auth/sso/callback?token=#{token}&state=#{state}"

To understand what goes wrong, let’s trace <%= j @callback_url %> step by step.

Step 1: The j helper (escape_javascript)

j escapes characters that would cause JavaScript syntax errors inside a string literal:

  • \\\
  • "\"
  • '\'
  • newlines → \n
  • </<\/ (prevents premature </script> tag termination)

& is not a special character in JavaScript, so j does not touch it.

Step 2: ERB <%= %> output processing

ERB’s <%= %> tag checks whether the output value is html_safe?. If it is not, ERB applies HTML escaping before outputting it. This is where & becomes &amp;.

Looking at the Rails escape_javascript source makes the behavior clear:

def escape_javascript(javascript)
  javascript = javascript.to_s
  if javascript.empty?
    result = ""
  else
    result = javascript.gsub(/(\\|<\/|\r\n|\342\200\250|\342\200\251|[\n\r"'])/u, JS_ESCAPE_MAP)
  end
  # html_safe propagation: the result is only html_safe if the input was html_safe
  javascript.html_safe? ? result.html_safe : result
end

A plain Ruby string (@callback_url) has html_safe? returning false. Therefore, the return value of j also has html_safe? as false. ERB then applies an additional round of HTML escaping on top.

The final JavaScript rendered into the HTML page looks like this:

// What actually appears in the HTML
window.location.href = "https://sp.example.com/callback?token=abc&amp;state=xyz";

Here is the critical insight: <script> tags are not parsed as HTML. According to the HTML specification, <script> is a “raw text element” — its contents are not processed as HTML markup. The browser does not decode HTML entities inside <script> blocks.

This is how the two contexts differ:

  • <a href="...&amp;state=xyz"> — The browser decodes &amp;& when parsing HTML attributes. The URL works correctly.
  • <script>... "&amp;state=xyz" ...</script> — The browser treats this as raw JavaScript text. &amp; goes directly into the JS string and then into the URL.

When the JavaScript executes window.location.href = "...&amp;state=xyz", the browser sends the literal string &amp;state=xyz as part of the query string.

Fix

Use raw combined with to_json:

<script>
  setTimeout(function() {
    window.location.href = <%= raw @callback_url.to_json %>;
  }, 2000);
</script>

How this works:

  • @callback_url.to_json: Converts the Ruby string to a JSON string literal. The output includes surrounding double quotes ("https://sp.example.com/..."), and & is preserved as-is. JSON escaping only handles ", \, and control characters.
  • raw: Bypasses ERB’s HTML escaping. Equivalent to explicitly marking the string as html_safe.

The j helper is not needed here because to_json already produces a valid JavaScript string literal — including the surrounding quotes — so it can be dropped directly into the script.

The rendered output is now correct:

// What appears in the HTML after the fix
window.location.href = "https://sp.example.com/callback?token=abc&state=xyz";

& is preserved, and the URL navigates correctly.


Reference: Encoding Rules Differ Between <a href> and <script>

When the same URL is used in two different locations in a template, the encoding rules are different:

<%# href attribute: HTML escaping is appropriate.
    The browser decodes &amp; → & when parsing HTML attributes. %>
<a href="<%= @callback_url %>">Link</a>

<%# script tag: HTML escaping must NOT be applied.
    The browser does not decode entities inside script tags. %>
<script>
  window.location.href = <%= raw @callback_url.to_json %>;
</script>

In an href attribute, <%= @callback_url %> encoding & as &amp; is actually correct behavior. The browser decodes HTML entities in attribute values as part of its HTML parsing step, so the actual URL used for navigation has & intact.

Inside a <script> tag, the browser switches from HTML parsing mode to JavaScript parsing mode. HTML entity decoding does not occur. Whatever &amp; appears in the JavaScript source goes into the JS string exactly as written.

This difference follows from the HTML specification’s distinction between “normal” parsing contexts (where character references are recognized) and raw text elements (where they are not).

Other common patterns where the same mistake can occur:

<%# Wrong patterns — all produce &amp; in the script context %>
<script>var url = "<%= @url %>";</script>
<script>var url = "<%= j @url %>";</script>
<script>var data = <%= @hash.to_json %>;</script>

<%# Correct patterns %>
<script>var url = <%= raw @url.to_json %>;</script>
<script>var data = <%= raw @hash.to_json %>;</script>

Summary

BugRoot CauseFix
state mismatch (session overwrite)Turbo Drive prefetch calls /initiate before the user clicksAdd data-turbo="false" to the SSO link
state mismatch (&amp;state=)<%= j url %> double-encodes & to &amp; inside <script>Use <%= raw url.to_json %> instead

Key Takeaways

Turbo Drive and state-mutating endpoints

In a Rails app using Turbo Drive, any endpoint that mutates server-side session state — SSO initiation, CSRF state generation, or anything under /auth/* — must disable Turbo Drive with data-turbo="false". These endpoints are not idempotent. Each invocation creates a new state, so Turbo’s prefetch behavior introduces real side effects that are nearly impossible to defend against server-side, since both the prefetch and the genuine request arrive with identical session cookies.

Embedding URLs in JavaScript from ERB

The default safe pattern for outputting a Ruby variable inside a <script> tag is <%= raw variable.to_json %>. The j helper escapes JavaScript special characters but does not prevent ERB’s HTML escaping from firing — and inside <script> blocks, HTML escaping corrupts URLs by converting & to &amp;. The browser treats <script> as raw text and will not decode HTML entities back to their original characters.

Debugging strategy for identical error messages

Both bugs produced the exact same error message: “state mismatch.” The temptation is to assume a single root cause. Instead, verify each layer independently: check the server logs for request count, inspect the raw parameter values in the logs, and verify the actual encoding of the URL as it arrives at the server. Fixing Bug 1 revealed Bug 2, which would have been invisible without the first fix. Incremental, layered debugging is the right approach when error messages are ambiguous.