The motivation
Our app is a privacy-first SaaS. The privacy policy describes, in plain language, what we collect, what we store, and how we secure it. Until last week, that policy would have been technically accurate but hard to defend if someone read it carefully:
We use industry-standard encryption for credentials in transit and at rest.
Sure. But the access token issued at login was sitting in localStorage, JS-readable by any script that ran on the page. Any successful XSS (through a copy-pasted dependency, a third-party widget, a logging snippet, or a bad CDN day) would let an attacker exfiltrate the access token and impersonate the user. "Industry-standard encryption at rest" doesn't help when the credential is in plaintext on the client and accessible via localStorage.getItem.
The fix is well-known: store the access token in an HttpOnly cookie so JS can't read it. The browser still sends it on every request. XSS can't see it, can't exfiltrate it. The privacy policy now describes a real HttpOnly session cookie instead of admitting JWTs sit in JS-readable storage.
That's the one-paragraph version. Here's what actually happened when we did it.
Surprise 1: ActionController::API doesn't have cookies
Our API controller inherits from ActionController::API, Rails's slimmed-down base class for JSON APIs. No views, no flashes, no form helpers. It also has no cookies. We didn't realise this until the first commit ran.
class MyController < ApplicationController
def login
# ...
cookies[:access_token] = { value: token, httponly: true, secure: true }
render json: { user: serialize(user) }
end
end
NoMethodError (undefined method `cookies' for an instance of MyController)
Five minutes of staring. The cookies API is part of ActionController::Cookies, which ActionController::Base includes by default and ActionController::API deliberately doesn't. The Rails team's reasoning is sound (JSON APIs traditionally don't deal in cookies), but it's a footgun if you're moving an existing API toward cookie-based auth.
The fix is one line in the base controller:
class ApplicationController < ActionController::API
# `ActionController::API` does not include the cookies helper by
# default (without this, `cookies[]` raises NoMethodError). Required
# for the JWT-cookie auth flow.
include ActionController::Cookies
end
That comment is now permanent. Future me, reading that file in six months, will not remember.
There's a related thing worth checking: the ActionDispatch::Cookies middleware needs to be in the request pipeline. It is by default, even in API-only Rails apps, but if you've ever called config.api_only = true and then tweaked the middleware stack manually, verify it's there with bin/rails middleware | grep Cookies. If it's missing, every cookies.delete on logout silently no-ops and your users can't log out.
Surprise 2: credentials: 'include' and the death of Access-Control-Allow-Origin: *
The frontend and backend live on different origins in our setup, so we have CORS configured to allow both.
To make the browser send the new auth cookies on cross-origin API calls, the frontend has to opt in:
fetch(`${apiUrl}/users/me`, {
credentials: 'include',
})
That credentials: 'include' is required. Without it, the browser does NOT send cookies cross-origin even if the same site set them. With it, the browser sends them, and the response must satisfy two extra constraints or the browser drops the response on the floor:
Access-Control-Allow-Credentials: trueAccess-Control-Allow-Originmust echo back the specific requesting origin. The wildcard*is rejected when credentials are involved.
The CORS gem's typical config has the wildcard:
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*' # <- This is fine for unauthenticated APIs.
resource '*'
end
end
Switch to credentialed CORS:
allow do
origins 'https://www.example.com', 'https://app.example.com'
resource '*', credentials: true
end
Two changes (explicit allowlist, credentials: true) and the gem handles the echo-back. Skipping either gets you this in the browser console:
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.
It's a clear error, but it doesn't appear until you actually send a credentialed request, which means you don't see it until your first cookie-auth login attempt. Easy to miss in dev if you're testing on localhost where you happen to be same-origin.
Surprise 3: not every double-submit cookie is safe
CSRF protection on cookie-auth is the next hurdle. The browser sends auth cookies automatically on every request, including ones triggered by <form> submits and <img> tags on a malicious site. That's the whole point of CSRF.
There are three patterns worth knowing, and the naming matters because two of them look almost identical in code:
- Synchronizer Token Pattern: server generates a token and stores it server-side against the session, embeds it in the page; client echoes it back as a header on mutating requests; server compares the submission against its stored copy. Secure, but stateful.
- Raw (unsigned) double-submit cookie: server sets a non-
HttpOnlycookie containing a random nonce; client reads it via JS and sends the same value as a header on mutating requests; server just checks that the cookie and the header match. Stateless, and — as we'll see — breakable. - Signed double-submit cookie: the same shape as raw double-submit, but the cookie value isn't a bare nonce. It's an HMAC bound to the user's authenticated session (here, the access token), signed with a server-side key. Stateless and resistant to the attack below.
Raw double-submit looks attractive (no server-side state, simple to implement). We almost shipped it. Then we noticed: our app gives every user a subdomain on the same parent domain. alex.example.com. priya.example.com. JS running on those subdomains is JS we don't fully control. And there's a vulnerability class called subdomain cookie tossing that turns "stateless CSRF" into "no CSRF":
An attacker on a sibling subdomain sets a cookie with the same name as your CSRF cookie, scoped to the parent domain (
.example.com). The browser merges sibling-set and main-site-set cookies; on the next request the attacker-chosen value is what the server sees. The double-submit equality check passes (because the attacker controls both the cookie and the header) and the server thinks the request is legitimate.
Raw (unsigned) double-submit is fundamentally vulnerable on any app with sibling-controllable subdomains: the equality check only proves that whoever set the cookie also set the header, which the attacker can do. The fix is to bind the CSRF token to the session in a way the attacker can't forge — which is exactly the signed double-submit cookie, and it's the variant OWASP explicitly recommends for this threat.
We use a signed double-submit cookie: the cookie value is a server-derived HMAC of the access token. Specifically:
# CSRF token signer
def self.generate(access_token)
digest = OpenSSL::HMAC.digest("SHA256", signing_key, access_token.to_s)
Base64.urlsafe_encode64(digest, padding: false)
end
def self.valid?(submitted, access_token)
return false if submitted.blank? || access_token.blank?
expected = generate(access_token)
return false unless submitted.bytesize == expected.bytesize # secure_compare needs equal lengths
ActiveSupport::SecurityUtils.secure_compare(submitted, expected)
end
The signing key is derived once from the app's secret via Rails' key generator, as a separate key from the JWT secret. Mixing secrets across security primitives is a textbook footgun: a leak of one key shouldn't compromise the other.
On login, the server sets two cookies plus a third:
access_token: the JWT,HttpOnly, the actual session.refresh_token: alsoHttpOnly, scoped to the refresh endpoint so it doesn't ride along on every request.csrf_token: NOTHttpOnly, value is the HMAC of the access token. The frontend reads it and echoes it back asX-CSRF-Token.
The validator on the server reads the access_token from its cookie, recomputes the expected CSRF token, and compares constant-time against the submitted header. Subdomain cookie tossing doesn't help an attacker here. They can set the cookie but they can't forge the signed value without the server-side key.
That comment now lives in the code:
Why NOT a raw double-submit cookie pattern: raw double-submit is vulnerable to subdomain cookie tossing. Our app has user-controlled subdomains; JS running there can set a cookie on the parent domain that the main app would read. With a signed double-submit cookie, the validator doesn't trust the cookie value as-is. It recomputes the expected HMAC from the access_token, which an attacker on a user subdomain cannot forge without the server-side signing key.
Surprise 4: the concurrent refresh race that logs everyone out
Once cookies were working, we noticed the auth flow falling apart under load. Open the dashboard, fire off 8 parallel API calls, force-expire the access token. Each of the 8 calls 401s. Each one tries to refresh. Each refresh rotates the refresh token (replay protection: used refresh tokens get marked consumed). The first refresh succeeds; the other 7 hit a now-invalid refresh token and fail; the user gets bounced to login.
The classic fix is a single-flight refresh promise. If a refresh is already in flight, every other caller awaits the same promise instead of starting their own:
let refreshInFlight: Promise<boolean> | null = null
async function refreshSession(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
try {
await authStore.getState().refresh()
return true
} catch (error) {
// ... handle ...
return false
} finally {
refreshInFlight = null // Reset for the next failure cycle.
}
})()
return refreshInFlight
}
Module-level let is the trick. The first 401 sets refreshInFlight; the next 7 401s hit the early return and await it. One refresh call. The retried original requests pick up the new access token from the rotated cookie. Everyone stays logged in.
This pattern is well-known but easy to miss when you're focused on the cookie-attribute side of the migration. If your existing api-client doesn't already coalesce refreshes, add this before you flip the cutover. We had it for the JWT-in-localStorage version of the api-client; it took five minutes to verify it survived the migration. Without it, the bug only surfaces under realistic concurrency, which means your dev testing won't catch it.
There's a related design choice: the sliding refresh window. A fresh refresh token has a multi-day expiry. On rotation, we issue a new one with a fresh multi-day expiry from now. An active user stays logged in as long as they're active (every API call resets the clock through the refresh path). An inactive user gets logged out a fixed window after their last activity. That's the contract you almost certainly want. The opposite (fixed expiry from initial login) means every user gets force-logged-out exactly N days after first login, regardless of how active they are.
Surprise 5: you can't ship this in one deploy
The frontend talks to the backend; both speak the cookie-auth protocol now. But during a deploy, you have a window where the frontend code and the backend code don't match. Specifically:
- Old frontend in a tab a user opened before the deploy. It sends
Authorization: Bearer .... The new backend has no Bearer fallback, so the user 401s. - Or: old backend hasn't rolled yet. The new frontend sends cookies but no Authorization header. The old backend ignores cookies, so the user 401s.
Either way, every active session gets logged out at deploy time. That's a bad day.
The pattern for cutting over without a logout-everyone moment is dual auth for one deploy cycle:
- Backend ships with both paths working. Accepts cookie auth or
Authorization: Bearer ..., returns cookies AND tokens-in-body on login. Existing frontends keep working unchanged. - Frontend ships next. Drops the Bearer header, drops the body-token reads, switches fully to cookies. Backend already supports this.
- 24 hours later, after smoke testing, a follow-up backend deploy removes the Bearer fallback. One-line cleanup; can ride a regular daily deploy.
The dual-auth window is uglier code than either end state, but it's the difference between zero auth-related incidents and "everyone got logged out at 14:00 UTC, please re-login." Document the window in the commit message and on the PR so future maintainers understand why the code looks like it's straddling two patterns.
What I'd do differently
If I were doing this from scratch, I'd:
- Stub
ActionController::Cookiesinto the API base controller from day one, even before there's a need. The five-minute footgun is removed, and the file'sincludelist now communicates "this controller can handle cookies." - Set up CORS with
credentials: trueand an explicit origin allowlist before the first endpoint that ever needs cookies. No wildcard ever, even on unauthenticated endpoints. The blast radius if you forget later is large. - Make CSRF token derivation a named service from day one with a clear interface (
generate(session_id)/valid?(submitted, session_id)), even if your first version is a raw double-submit. The interface gives you somewhere to plug a stronger algorithm in later without rewriting the controllers. - Single-flight your refresh logic in your api-client before you ship cookie auth. If you already do JWT refresh, you should have it; if you don't, add it as a refactor pre-step. It's the same pattern either way.
Lessons
1. ActionController::API excludes more than you remember.
Cookies, sessions, browser-specific helpers: all opt-in. Read what ActionController::Base includes (bin/rails console; ActionController::Base.ancestors) and treat the diff as a checklist of things you might need. The Rails Guides cover this but it's easy to miss until you trip on it.
2. Cookie auth is a multi-system change masquerading as a one-system change.
CORS settings, frontend fetch options, backend filters, deploy ordering, replay protection: all need coordinated changes. Treat the migration as a 5-PR sequence (or 5 commits in one PR with a clear sequence), not a single "switch auth" change.
3. Subdomains change which CSRF patterns are safe.
If your product has sibling subdomains that any user can control (user-published sites, custom hostnames, white-label tenant apps), raw (unsigned) double-submit is broken. A signed double-submit cookie — an HMAC bound to the session, or a stateful Synchronizer Token — is the minimum viable pattern. The cookie-attribute defenses (__Host- prefix, SameSite=Strict) are valuable but not sufficient on their own.
4. The cutover deploy pattern matters more than the code.
You can write perfect cookie auth and still have an outage if the deploy ordering is wrong. Plan for the dual-auth window, deploy the frontend after the backend, run for 24 hours before the cleanup deploy. The commit messages on each step should call out why the code is in its transitional shape.
5. Privacy claims are easier to defend when the architecture matches.
The privacy policy described "secure session storage" before this migration: technically true (localStorage is per-origin), but indefensible under any real scrutiny. After: HttpOnly cookies the JS layer literally cannot read. The privacy story now matches the implementation. That's the kind of change that's worth doing even if it weren't also a security improvement.
Reading list
- OWASP: Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet (defines the Synchronizer Token, raw double-submit, and signed double-submit patterns, and recommends the signed variant)
- OWASP: Double Defeat of the Double-Submit Cookie (David Johansson) (how cookie tossing breaks unsigned double-submit, and why session-bound signing fixes it)
- MDN: Using the Fetch API — Including credentials (the
credentials: 'include'opt-in and the wildcard-vs-explicit-origin rule) - Rails Guides: API-Only Applications (the canonical list of what
ActionController::APIdoes and doesn't include)
