Skip to main content

The cookies and CSRF

A cookie login leaves two cookies in the browser.

  • Both are set once, at login, and deleted at logout.
  • Neither ever changes in between. Nothing refreshes.
AttributeValueWhy
Namejodapp_session_id for users, teamjod_session_id for adminsThe name says which system set it. A generic name such as session_id could collide with another app's cookie on the same domain
ValueThe id of the Identities::UserSession, signed with secret_key_baseReadable, so nothing secret is in it. Signed, so a changed value fails the check
HttpOnlyYesNo script on any page can read it
SecureYes, outside development and testHTTPS only. Tests run over plain HTTP, so a Secure cookie would never come back
SameSiteLaxExplained below
DomainFrom config.x.session_cookie_domains, per identity systemExplained below
Path/The whole API
ExpiresLogin time plus the absolute limitThe cookie cannot outlive the longest possible session
AttributeValueWhy
NameCSRF_TOKEN for users, TEAM_CSRF_TOKEN for admins
Valuecsrf_token from the Identities::UserSession, as it isThe page's API client copies it into the X-CSRF-Token header
HttpOnlyNoPage scripts must be able to read it. The sessions page explains why that is safe
Secure, SameSite, Domain, Path, ExpiresThe same as the session cookieThe two cookies live and die together

The domain is a deployment fact, so it comes from configuration and never from the request.

  • config.x.session_cookie_domains holds one domain per identity system, per environment.
  • Rails never derives the domain from request.host.
    • Turning api.teamjod.app into .teamjod.app is guesswork. That guess once put .jodapp.com on an admin cookie, and the browser refused it.
  • The domain must cover both the web host and the API host of one identity system.
    • The JodApp Web server must receive the cookie on a page request to jodapp.com.
    • The browser must send the same cookie to api.jodapp.com.
    • A cookie set without a domain is host-only and would reach only the API host.
EnvironmentUsers: web host and API hostUsers: cookie domainAdmins: web host and API hostAdmins: cookie domain
Productionjodapp.com calls api.jodapp.com.jodapp.comteamjod.app calls api.teamjod.app.teamjod.app
QAjodapp.dev calls api.jodapp.dev.jodapp.devteam.jodapp.dev calls api.jodapp.dev.jodapp.dev
Development and testjodapp.test:5173 and the local API:allteamjod.test:5173 and the local API:all
  • :all tells Rails to derive the domain from the request host.
    • It is allowed only in development and test, where every developer's hosts differ and no real cookie is at risk.

SameSite=Lax​

Lax decides when the browser sends the cookie.

  • It sends the cookie on every request to the same site, which includes a fetch from jodapp.com to api.jodapp.com.
  • It sends the cookie when a person arrives from another site by a top-level navigation, such as clicking a link in an email.
  • It never sends the cookie on a cross-site POST or on a cross-site fetch.

That is enough for us because every web host and its API share one site, as the table above shows.

  • A cross-site form post to our API arrives with no session cookie, so Rails answers 401 before any CSRF check runs.
  • "Same site" includes every subdomain, so a script on another *.jodapp.com host could still make the browser send the cookie. The CSRF token covers that case.

One check before a production deploy, because QA cannot show it:

  • List every place where a third party sends a signed-in browser back to us, such as a SingPass return or a payment provider return.
  • Confirm that each arrives as a GET. A cross-site POST landing would arrive without the session cookie.

CORS and allowed hosts​

Two more settings decide which browsers and which hostnames may reach the API at all.

  • config/initializers/cors.rb lists the web origins per environment, with credentials: true.
    • Only those origins may make a fetch with cookies to the API. In production they are jodapp.com, careers.jodapp.com, jobs.jodapp.com, teamjod.app and gig-partners.jodapp.com.
    • A fetch with a JSON body from any other origin is stopped by the browser before Rails sees it.
  • config.hosts in each environment file lists the hostnames Rails answers to.
    • The public API hosts, and the internal names used by health checks and by the JodApp Web server: Docker container names, the VPC range and localhost.

CSRF, step by step​

  1. At login, Rails generates a random csrf_token.
    • It stores it on the Identities::UserSession and sends it in the readable cookie.
  2. The API client in the page copies the cookie into the X-CSRF-Token header.
    • It does this on every request that is not GET or HEAD, in one place, so no page has to remember it.
  3. On every cookie-authenticated request that is not GET or HEAD, Rails compares the header with csrf_token on the Identities::UserSession.
    • The comparison uses ActiveSupport::SecurityUtils.secure_compare, so its timing reveals nothing.
    • No header answers 403 with code csrf_token_missing.
    • A header that does not match answers 403 with code csrf_token_invalid.
    • Neither ever answers 401. The session is fine. Only the header is wrong.
  4. A bearer-token request skips the check.
    • The browser never adds an Authorization header on its own, so there is nothing for a cross-site page to forge.
  5. GET and HEAD requests skip the check.
    • So a GET must never change data. That is the Rails guide's first CSRF rule, and every endpoint on this page follows it.

Why Rails' own protect_from_forgery is not used:

  • Its origin check requires the Origin header to equal the API's own URL.
    • Our pages live on jodapp.com and call api.jodapp.com, so every write would fail that check.
  • With the origin check turned off, what remains is token masking, which protects tokens embedded in HTML pages.
    • The API serves no HTML, so the masking protects nothing here.

Login itself carries no CSRF token, because no session exists yet. Three things protect it instead.

  • The body must be JSON. An HTML form on another site cannot send JSON.
  • A fetch from another site with a JSON body must pass CORS first, and only our origins are allowed.
  • The rate limit from the login endpoint caps guessing.