Changing a session
A session changes only through Rails, and JodApp Web notices only through revalidation.
- The change starts in the browser, in a
clientAction.- React Router gives a route two kinds of action export.
actionruns on Node.clientActionruns in the browser. - JodApp Web uses
clientActionfor every session change, and no route exports a serveraction.
- React Router gives a route two kinds of action export.
- Rails changes the database and the cookies.
- React Router loads route data again, and the root loader reads the new session.
- Nothing in JodApp Web updates a session by hand.
- This page covers six things.
- What counts as a session change.
- Why every one of them is a
clientActionand not a serveraction. - Login, then logout and sign out everywhere, step by step.
- How the API client adds the CSRF header.
- What each refused response means.
- When the app notices a change.
The words this page uses
| Word | Meaning |
|---|---|
| React Router | The library that runs JodApp Web's routes, in both runtimes. In the browser it handles every click and form submit, runs the clientLoader and clientAction exports, and sends .data requests to Node. On Node it runs the middleware and loader exports for each request. When a page says React Router does something, it means this library code, not code we wrote |
| Route action | The clientAction export of a route. It runs in the browser when a form on that route submits. When it returns, React Router loads route data again. A server action also exists in React Router, and JodApp Web does not use one |
| Resource route | A route with a path and no component. It exports an action and nothing else, so it never draws anything. /logout is one |
error.code | The code field Rails sends with every error, carried by ApiError in app/errors/api-error.js. A client decides what to do from the code, never from the message text |
| The API client's hook | The beforeRequest hook in app/api/ky-client.js. It runs before every request the API client sends and adds the X-CSRF-Token header when one is needed |
replace() | The React Router redirect that replaces the current history entry. A person who presses Back does not return to the form they just submitted |
data() | The React Router helper that returns a value with an HTTP status from an action. data({ error }, { status: 400 }) gives the form its message |
| Revalidation | React Router loading route data again, so the page shows current records. In the browser it sends one .data request to Node for the matched loader exports, and runs the matched clientLoader exports. React Router revalidation in JodApp Web states the rules |
What counts as a session change
A session change is any write that can change the current-session JSON. Use a clientAction for every one of them.
| Change | What moves in the current-session JSON |
|---|---|
| User login | The session goes from null to a person |
| User logout, and sign out everywhere | The session goes from a person to null |
| Admin login and logout | The same, for identitiesAdminSession |
| Talent setup completion | talent_access.state moves from setup_required to ready |
| Employer account creation | employer_access.state moves from missing to active |
| A change to the person's name or email | identities_user carries those fields |
| The password reset | The session goes from a person to null on every device. Rails ends every session of the person when it saves the new password. The browser makes no session call for it |
A write that cannot change the current-session JSON is normal page work, and this page does not cover it. Three examples:
- editing a job
- uploading a CV
- switching the company the person acts as, which changes
is_selectedon twoOrg::Membershiprows and nothing in the current-session JSON
Every session change is a clientAction
The browser makes every session change, because only the browser holds the credential that Rails will accept for a write.
- The browser sends both cookies on its own, and the API client adds
X-CSRF-Token. - Node never writes. JodApp Web D3 — Node reads, the browser writes, Rails decides records why.
Take the logout button as the example. It is a React Router <Form> that posts to /logout, and /logout is a route whose file exports a clientAction.
// Inside the navbar, or anywhere a "Log out" button appears
import { Form } from 'react-router'
<Form method="post" action="/logout">
<button type="submit">Log out</button>
</Form>
What happens when Mei clicks it, in order:
- React Router catches the submit in the browser. No HTTP form post reaches Node.
- It finds the route whose path is
/logout, the fileapp/routes/logout.js, and runs that file'sclientAction.
- It finds the route whose path is
- The
clientActioncalls Rails through the API client.identitiesUserSessionsApi.destroy()sendsDELETE /identities/user_sessions/current.- The browser adds both cookies. The API client adds
X-CSRF-Token.
- Rails deletes the
Identities::UserSession, and deletes both cookies in its response. - The
clientActionreturnsreplace('/').- A login action returns
replace(target)on success, ordata({ error }, { status: 400 })when the form must show a message.
- A login action returns
- React Router navigates to
/and loads route data again.- It sends one
.datarequest to Node for the matchedloaderexports. That request carries no session cookie now, so the root loader answersnull. - Every policy and every component sees the new session on that same reload.
- It sends one
How a form finds its clientAction:
| The form | Submits to |
|---|---|
<Form method="post" action="/logout"> | The clientAction of the route at /logout |
<Form method="post"> with no action attribute | The clientAction of the route the page is on |
useSubmit() called from a handler, as the login form does | The clientAction of the route the page is on, /login, so login-page.jsx receives it |
After the clientAction succeeds, the code does nothing else.
- No update to a provider, because there is no provider.
- No copy of the response into state.
- No navigation by hand to show the new state. The redirect is the whole answer.
Login, step by step
Login is a clientAction on the login page. Mei signs in from her laptop.
Legend: The orange box is JodApp Web, this page's subject. The write goes straight from the browser to Rails. The read that follows goes through Node, on the
.datarequest the redirect causes. The action itself reads nothing from the login response. The cookies are the change.
The action, on the user login page:
// app/routes/login-page.jsx
import { data, replace } from 'react-router'
import { identitiesUserSessionsApi } from '@/api/identities-user-sessions-api'
import { IDENTITIES_USER_SESSIONS_ERROR_CODES } from '@/domains/identities-session/identities-session-constants'
import { authSafeRedirect } from '@/auth/auth-safe-redirect'
import { authEntryRules } from '@/auth/auth-entry-rules'
export const clientAction = async ({ request }) => {
const { identifier, password } = await request.json()
try {
await identitiesUserSessionsApi.create({ identifier: identifier, password: password })
} catch (error) {
// Two codes are messages for the form. They go back as 400, never as Rails's
// own status, because a failed login changed nothing about the session.
if (error.code === IDENTITIES_USER_SESSIONS_ERROR_CODES.INVALID_LOGIN) return data({ error: 'Wrong email, phone number or password.' }, { status: 400 })
if (error.code === IDENTITIES_USER_SESSIONS_ERROR_CODES.RATE_LIMITED) return data({ error: 'Too many attempts. Try again in a few minutes.' }, { status: 400 })
// Everything else is not the person's mistake. It reaches the error boundary.
throw error
}
const target = authSafeRedirect.internalPathOrDefault(
authSafeRedirect.redirectTarget(new URL(request.url)),
'/',
authEntryRules.USER_GUEST_PATHS
)
return replace(target)
}
What each part does:
| Part | Why |
|---|---|
request.json() | The form submits with encType: 'application/json', so the action reads a JSON body. react-hook-form and zod validate the fields before the submit |
error.code | Rails names each failure. The action switches on the code, never on the status, because 422 also answers other validation failures and 403 also answers a refused permission. The codes live in IDENTITIES_USER_SESSIONS_ERROR_CODES, in app/domains/identities-session/identities-session-constants.js, so no page compares error.code with a string |
status: 400 on a form message | The root routes load route data again when an action fails with 401 or 403, because those mean the session is stale. A wrong password changed nothing, so it must not look like a stale session |
throw error | A 403 with a CSRF code, a 422 with credential_not_allowed, a 5xx and a network failure are not the person's mistake. The error boundary shows them and reports them |
authSafeRedirect | redirect_to comes from the URL, so a stranger can set it. The helper allows only a path inside our site, and refuses the login pages themselves so nobody loops |
replace(target) | The login page is not a destination. Back must not return to it |
The page reads the message with useActionData() and shows it above the form. The employer login page and the admin login page have the same shape, with their own default target and their own guest paths.
What login returns is the current-session JSON, and the action does not read it. The cookies in the response are the change, and the reload after the redirect reads the truth through the normal path.
Logout, and sign out everywhere
Logout is a clientAction on a resource route. A logout button is a form that posts to that route.
<Form method="post" action="/logout">
<button type="submit">Log out</button>
</Form>
| Resource route | File | Calls | Ends |
|---|---|---|---|
/logout | app/routes/logout.js | DELETE /identities/user_sessions/current | This browser's session |
/logout/everywhere | app/routes/logout-everywhere.js | DELETE /identities/user_sessions | Every session of this person, on every device |
/team/logout | app/routes/team/team-logout.js | DELETE /team/identities/admin_sessions/current | This browser's admin session |
/team/logout/everywhere | app/routes/team/team-logout-everywhere.js | DELETE /team/identities/admin_sessions | Every session of this admin |
Legend: The write goes straight from the browser to Rails, with the CSRF header the API client added. The read that follows reaches Node without a cookie, so Node calls nothing and answers
null. Nobody told the app "we are signed out". The cookie is gone, and the next request shows it.
The action:
// app/routes/logout.js
import { replace } from 'react-router'
import { identitiesUserSessionsApi } from '@/api/identities-user-sessions-api'
export const clientAction = async () => {
try {
await identitiesUserSessionsApi.destroy()
} catch (error) {
// 401 means there was no session left to end. The outcome is the same: signed out.
if (error.status !== 401) throw error
}
// This route draws nothing and sits under no policy, so nothing else would send
// the person anywhere. replace(): the page after logout is a correction, not a destination.
return replace('/')
}
Sign out everywhere is the same file shape, calling identitiesUserSessionsApi.destroyAll(). Rails deletes every session of the person, this browser's included, so the redirect is the same.
The button that posts to /logout/everywhere sits where the plain log out already is: one item below "Log out" in the navbar's account menu, on every user navbar, behind the same confirm dialog. It says "Sign out of all devices". The Team button sits in the Team sidebar, under the existing log out form, with the same words. The words match the reset-password message so the two read as one idea. On the employer dashboard the plain log out asks the person to confirm as well, because an employer is often on a shared computer and a wrong click there costs more than one on a phone.
Why logout lives on a resource route and nowhere else:
| Route that could own logout | Why not |
|---|---|
| A visual layout | A visual layout exports no action. Auth would creep back into layouts |
The login page, behind an intent field | One URL would do two opposite things, and the route tree would not show that the login page can end a session |
| A page with a component | Logout shows nothing. A route with nothing to show has no component |
What the person sees after logout in another tab: nothing, until the next click. That click reaches Node without a cookie, the middleware answers null, and the policy on the destination sends the person to the login page.
The password reset signs the person out of every device, and the page says so
Rails ends every session of the person when it saves a new password. The user side does it in PATCH /identities/users/password, and the Team side in PATCH /team/identities/admins/password. The rule and its reason are on the endpoints page, under Other endpoints that end sessions.
JodApp Web makes no session call for it. Its job is to tell the person, because the person did not press a sign-out button and another device will ask them to sign in without warning.
| Page | What it says, and when |
|---|---|
The user reset page, /reset-password | Before the person saves: saving the new password signs them out of all their devices. In the success message: they are signed out of all their devices and sign in again with the new password |
| The Team reset page | The same two messages, for the admin |
Both messages use plain words. They say "all your devices", not "sessions", because the people who read them are workers and employers, not engineers.
How the API client adds the CSRF header
Rails compares X-CSRF-Token with the session's csrf_token on every cookie-authenticated request that changes data. The API client adds that header in one place, so no action has to remember it.
// app/api/ky-client.js, inside createApiClient({ csrfCookieName, prefixUrl })
beforeRequest: [
(request) => {
const isWrite = !['GET', 'HEAD'].includes(request.method)
const isBrowser = typeof window !== 'undefined'
if (!isWrite || !isBrowser) return
const csrfToken = readCookie(csrfCookieName, document.cookie)
if (csrfToken) request.headers.set('X-CSRF-Token', csrfToken)
}
]
| Rule | Why |
|---|---|
The header goes on every request that is not GET or HEAD | Those are the requests Rails checks. A GET never changes data, so it carries no header |
The header is read from document.cookie, in the browser only | The CSRF cookie is readable by scripts on purpose. Node never writes, so Node never needs the header |
| No cookie means no header | A signed-out browser sends a login POST with no CSRF cookie. Login needs no header, and sending the string undefined would be a bug, not a token |
credentials: 'include' stays on the client | The page on jodapp.com calls api.jodapp.com. Without it the browser would send no cookies to the API at all |
The Team client reads TEAM_CSRF_TOKEN | The two identity systems share the code of the hook and nothing else |
What the API client does when Rails refuses a request:
- It converts the response into an
ApiErrorwithstatusandcode, and returns it to the caller once. - It never retries, never navigates, and never clears anything.
- A refused call may be a raced fetch losing to a policy redirect that is already in flight. If the client reacted on its own, it would fight the policy's navigation.
- JodApp Web D1 — Nothing refreshes; a
401means the session is gone records this rule.
What a refused response means
Every Rails error carries a status and a code. The code decides what the caller does.
| Status | Code | Meaning | What the caller does |
|---|---|---|---|
401 | unauthorized | The session is gone. The person is signed out | In a clientAction: return the error with its status, so the root routes reload the session and the policy on the page sends the person to login. In a direct call: revalidate(), and nothing else |
403 | csrf_token_missing, csrf_token_invalid | The header was not sent, or does not match. The session is fine. This is a mistake in our code | Throw. The error boundary shows it and reports it. Never treat it as signed out |
403 | Any other code, such as org_membership_required or org_membership_inactive | Signed in, but the access facts changed | In a clientAction: return the error with its status, so the root routes reload the session and the policies read the new state. In a direct call: revalidate(), and nothing else |
422 | invalid_login | Wrong identifier or password | A form message, returned as 400 |
422 | verification_required | The account has neither a verified email nor a verified phone | The login page sends the person to the forgot-password OTP, which marks the email verified when it completes |
422 | credential_not_allowed | A browser asked for a bearer token. A mistake in our code | Throw |
429 | rate_limited | Too many login attempts from this address | A form message, returned as 400 |
5xx, or no response | any, or status: 0 | Rails failed, or the network did | Throw. The error boundary shows an error page |
Two rules hold across the table.
- Only
401means signed out. A403never does.- The CSRF codes and the access codes share a status and mean different things. The code tells them apart.
- Only the person's own mistakes become form messages.
- Everything else is thrown, so a bug in our code is seen and reported, and never shown as "please sign in again".
The four Rails endpoints hold the error table on the Rails side.
When the app notices a change
Not every change starts in this browser tab. Be precise about when each kind becomes visible.
| The change | How this tab notices |
|---|---|
A clientAction in this tab succeeds | At once. React Router loads route data again |
A clientAction in this tab fails with 401 or 403 | At once. The root routes opt back in to the reload, and the policy on the page acts on the new session |
A direct API call from a component is refused with 401 or 403 | When the caller asks with revalidate(). React Router does not see a call that did not pass through it |
| Logout in another tab | On the next request that reaches Node or Rails: a document request, a click that enters a policy's area, or a fetch that Rails refuses and the caller answers with revalidate(). The cookie is gone, so the middleware answers null and the policy redirects |
| An admin disables the company | On the next refused request, or on the next click. Rails reads the database on every request, so the stale copy in this tab grants nothing |
Between those moments, the tab holds a stale session. That is acceptable because the session grants nothing. Rails checks the database on every protected request, so a stale "active" in the browser cannot read one byte of protected data.