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.
The session cookie
| Attribute | Value | Why |
|---|---|---|
| Name | jodapp_session_id for users, teamjod_session_id for admins | The name says which system set it. A generic name such as session_id could collide with another app's cookie on the same domain |
| Value | The id of the Identities::UserSession, signed with secret_key_base | Readable, so nothing secret is in it. Signed, so a changed value fails the check |
HttpOnly | Yes | No script on any page can read it |
Secure | Yes, outside development and test | HTTPS only. Tests run over plain HTTP, so a Secure cookie would never come back |
SameSite | Lax | Explained below |
Domain | From config.x.session_cookie_domains, per identity system | Explained below |
Path | / | The whole API |
Expires | Login time plus the absolute limit | The cookie cannot outlive the longest possible session |
The CSRF cookie
| Attribute | Value | Why |
|---|---|---|
| Name | CSRF_TOKEN for users, TEAM_CSRF_TOKEN for admins | |
| Value | csrf_token from the Identities::UserSession, as it is | The page's API client copies it into the X-CSRF-Token header |
HttpOnly | No | Page scripts must be able to read it. The sessions page explains why that is safe |
Secure, SameSite, Domain, Path, Expires | The same as the session cookie | The two cookies live and die together |
Where the cookie domain comes from
The domain is a deployment fact, so it comes from configuration and never from the request.
config.x.session_cookie_domainsholds one domain per identity system, per environment.- Rails never derives the domain from
request.host.- Turning
api.teamjod.appinto.teamjod.appis guesswork. That guess once put.jodapp.comon an admin cookie, and the browser refused it.
- Turning
- 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.
- The JodApp Web server must receive the cookie on a page request to
| Environment | Users: web host and API host | Users: cookie domain | Admins: web host and API host | Admins: cookie domain |
|---|---|---|---|---|
| Production | jodapp.com calls api.jodapp.com | .jodapp.com | teamjod.app calls api.teamjod.app | .teamjod.app |
| QA | jodapp.dev calls api.jodapp.dev | .jodapp.dev | team.jodapp.dev calls api.jodapp.dev | .jodapp.dev |
| Development and test | jodapp.test:5173 and the local API | :all | teamjod.test:5173 and the local API | :all |
:alltells 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
fetchfromjodapp.comtoapi.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
401before any CSRF check runs. - "Same site" includes every subdomain, so a script on another
*.jodapp.comhost 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.rblists the web origins per environment, withcredentials: true.- Only those origins may make a
fetchwith cookies to the API. In production they arejodapp.com,careers.jodapp.com,jobs.jodapp.com,teamjod.appandgig-partners.jodapp.com. - A
fetchwith a JSON body from any other origin is stopped by the browser before Rails sees it.
- Only those origins may make a
config.hostsin 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
- At login, Rails generates a random
csrf_token.- It stores it on the
Identities::UserSessionand sends it in the readable cookie.
- It stores it on the
- The API client in the page copies the cookie into the
X-CSRF-Tokenheader.- It does this on every request that is not GET or HEAD, in one place, so no page has to remember it.
- On every cookie-authenticated request that is not GET or HEAD, Rails compares the header with
csrf_tokenon theIdentities::UserSession.- The comparison uses
ActiveSupport::SecurityUtils.secure_compare, so its timing reveals nothing. - No header answers
403with codecsrf_token_missing. - A header that does not match answers
403with codecsrf_token_invalid. - Neither ever answers
401. The session is fine. Only the header is wrong.
- The comparison uses
- A bearer-token request skips the check.
- The browser never adds an
Authorizationheader on its own, so there is nothing for a cross-site page to forge.
- The browser never adds an
- 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
Originheader to equal the API's own URL.- Our pages live on
jodapp.comand callapi.jodapp.com, so every write would fail that check.
- Our pages live on
- 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
fetchfrom 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.