The four endpoints
Each identity system has four endpoints.
- They are the only way a session is created, read, or ended.
- There is no refresh endpoint.
| What it does | Users | Admins |
|---|---|---|
| Login: create a session | POST /identities/user_sessions | POST /team/identities/admin_sessions |
| Read the current session | GET /identities/user_sessions/current | GET /team/identities/admin_sessions/current |
| Logout: end the current session | DELETE /identities/user_sessions/current | DELETE /team/identities/admin_sessions/current |
| Sign out everywhere: end every session of this person | DELETE /identities/user_sessions | DELETE /team/identities/admin_sessions |
The two columns behave the same way.
- The rest of this section describes the user endpoints.
- For the admin ones, read
identities_admin_sessions,teamjod_session_idandTEAM_CSRF_TOKEN.
Login
Who calls it:
- the JodApp Web browser, from the login page
- the mobile app, from its login screen
- never the JodApp Web server
The request body:
| Field | Users | Admins |
|---|---|---|
identifier | The email address or the mobile number | not used |
email | not used | The email address |
password | The password | The password |
credential | :cookie, the default, or :token for the mobile app | The same |
What Rails does, in order:
- Counts login attempts from the caller's IP address.
- The address is the person's own, not the Cloudflare edge's: Rails trusts Cloudflare's published ranges as proxies. See Identities D5, the row "The API sits behind Cloudflare".
- More than 10 in 3 minutes answers
429. - The rest of the steps do not run.
- Finds the person and checks the password.
has_secure_passwordcompares in constant time.- A wrong identifier, a wrong password, or a deleted account all answer the same
422with codeinvalid_login. - An unverified account answers
422with codeverification_requiredinstead, so the client can send the person to the OTP that verifies them.- An unverified account is one where
is_email_verifiedandis_phone_verifiedare both false. - A normal sign-up verifies by OTP before a password exists, so today only the JodGig migration creates one: it copies
is_phone_verifiedand setsis_email_verified: false. - The ruling is Identities D5, the row "An unverified account at login".
- An unverified account is one where
- The message is generic. The response never says which part was wrong, so it never reveals that an account exists.
- Creates one
Identities::UserSession.- It holds the owner, a new
uuid, a randomcsrf_token, and the caller'sip_addressanduser_agent. - It also sets
last_loginon the person's own record, as today. - A token login also stores the token's SHA-256 digest in
token_digest.
- It holds the owner, a new
- Sets the credential.
- web: the two cookies from the picture of the login response.
- mobile: no cookie.
- Answers
200with the current-session JSON.- mobile:
tokenis added at the top level, once. Rails never shows the token again.
- mobile:
A browser origin that asks for credential: :token is refused.
- Rails answers
422with codecredential_not_allowed. - A bearer token in a browser would be readable by scripts, which is what the
httponlycookie exists to prevent. - Until the mobile app's first issue builds the token path, every login that asks for
credential: :tokenis refused the same way, from any origin.
A cookie login, shortened:
POST /identities/user_sessions HTTP/1.1
Host: api.jodapp.com
Content-Type: application/json
{ "identifier": "mei@example.com", "password": "..." }
HTTP/1.1 200 OK
Set-Cookie: jodapp_session_id=<signed session id>; ...; HttpOnly; SameSite=Lax
Set-Cookie: CSRF_TOKEN=<random string>; ...; SameSite=Lax
{ "identities_user": { ... }, "talent_access": { ... }, "employer_access": { ... } }
A token login from the mobile app, shortened:
POST /identities/user_sessions HTTP/1.1
Host: api.jodapp.com
Content-Type: application/json
{ "identifier": "mei@example.com", "password": "...", "credential": "token" }
HTTP/1.1 200 OK
{ "token": "<random string, shown once>", "identities_user": { ... }, "talent_access": { ... }, "employer_access": { ... } }
Read the current session
Who calls it:
- the JodApp Web server, on every page request, with the person's cookie forwarded
- the JodApp Web browser, after a login and whenever a page asks who is signed in
- the mobile app, when it starts
What Rails does, in order:
- Finds the
Identities::UserSession.- web: a
jodapp_session_idcookie gives the session'sid, after the signature check. - mobile: an
Authorization: Bearerheader gives a token, and its SHA-256 digest finds theIdentities::UserSessionthroughtoken_digest. - A request with neither answers
401.
- web: a
- Checks the
Identities::UserSessionis not expired.updated_atmust be within the idle limit andcreated_atwithin the absolute limit.- An expired session answers
401, the same as a missing one.
- Touches
updated_at, but only when it is older than 15 minutes.- Most reads write nothing.
- Answers
200with the current-session JSON.- The response carries
Cache-Control: no-store. - The response never sets a cookie.
- The response carries
The current-session JSON holds three keys and nothing else. Identities D6 — The current-session answer holds the person and one access word per area says what may join it.
{
"identities_user": { "id": 42, "first_name": "Mei", "last_name": "Tan", "email": "mei@example.com" },
"talent_access": { "state": "setup_required" },
"employer_access": { "state": "active" }
}
- Each
stateis one word. JodApp Web — The facts a rule may read lists the words. - Which company the person acts as is not in this JSON. It is the entry with
is_selected: truein theorg_membershipslist.
This is the endpoint the JodApp Web server calls most.
- It costs one primary-key read on a small table, then the reads that build the JSON.
Logout
Who calls it:
- the JodApp Web browser, from its logout route, with
X-CSRF-Token, because aDELETEchanges data - the mobile app, from its sign-out button
What Rails does, in order:
- Finds the
Identities::UserSession, as in "Read the current session".- No valid session answers
401, and the client treats that as already signed out.
- No valid session answers
- Destroys the
Identities::UserSession. - Answers
204.- web: both cookies are deleted in the response.
- mobile: nothing more to delete on the server. The app forgets its token.
Sign out everywhere
Who calls it: the same two callers, from a "sign out of all devices" action.
What Rails does, in order:
- Finds the
Identities::UserSession, as in "Read the current session". - Destroys every
Identities::UserSessionof the same owner, the current one included. - Answers
204.- web: both cookies are deleted in the response.
- Every other browser and device of that person gets
401on its next request.
Other endpoints that end sessions
| Endpoint | What it does to sessions | Why |
|---|---|---|
The password reset: POST /identities/users/password/forgot sends an OTP, then PATCH /identities/users/password sets the new password with it | Destroys every Identities::UserSession of that person | The person resets because the password was lost or leaked, so any open session may belong to someone else. The person is not signed in, so there is no current session to keep |
PATCH /team/identities/admins/password, which sets the password with the token from an invite or reset email | Destroys every Identities::AdminSession of that admin | The same |
Account deletion, when it is built: the step that soft-deletes an Identities::User and wipes the personal data from its row (see Account deletion and the PDPA) | Destroys every Identities::UserSession of that person, in the same step | The account is gone. A session left alive would keep acting as a deleted account until it expires. Ending the sessions in the same step means nothing depends on a later check |
No endpoint lets a signed-in person change their password today. When one is built, it keeps the current session and destroys every other session of that person, because the person has proved they hold the device they are on.
The org_memberships list
GET /identities/users/current/org_memberships lists every Org::Membership of the signed-in Identities::User, with its role and its company.
- An
Org::Membershipconnects oneIdentities::Userto oneOrg::Company. One person can hold several. - It is not one of the four session endpoints. It needs a valid session, and it answers for the person that session names.
- The current-session JSON leaves every membership fact out on purpose, apart from the one word in
employer_access.state.- This endpoint answers which company the person acts as: the entry with
is_selected: true. - It also answers each membership's role and its company's display data.
- This endpoint answers which company the person acts as: the entry with
- Admins have no
Org::Membership, so this endpoint has no admin twin.
Who calls it:
- the JodApp Web server, from the
loaderofapp/layouts/org-dashboard/org-dashboard-layout.jsx, with the person's cookie forwarded- That file is the layout around every page of the employer area.
- Its
loaderruns on every navigation inside the employer area:- when a person enters the area
- on every click between two of its pages
- on every reload
- The
loaderkeeps one entry: the one withis_selected: true. - When no entry has
is_selected: true, theloaderkeeps none and answers{ orgMembership: null }. The pages inside the employer area then show no company data. A person with no usable membership never reaches thatloader, becauseemployer-required-policysends them to/employers/access-unavailablefirst.
What Rails does, in order:
- Sets
Cache-Control: no-storeon the response.- This runs before the session check, so a
401carries the header too. - The list belongs to one signed-in person, so no browser or proxy may store it.
- This runs before the session check, so a
- Finds the
Identities::UserSessionfrom thejodapp_session_idcookie, as in Read the current session.- A missing cookie, or a cookie whose signature fails, answers
401. - A session that is gone or expired answers
401, the same as a missing one. - Rails touches
updated_atonly when it is older than 15 minutes.
- A missing cookie, or a cookie whose signature fails, answers
- Refuses a deleted person.
- An
Identities::Userwithis_deleted: trueanswers401.
- An
- Loads every
Org::Membershipwhoseuser_idis the person'sid, in one query.- Every row comes back, whether the person can use it or not. A revoked membership, or a membership of a disabled company, is in the list. Its
access_statussays why the person cannot use it. - The query also loads each company and each company's
geo_areasrow, so building the JSON reads nothing more. - The order is
org_companies.name, A to Z. - When two entries have the same company name,
org_memberships.iddecides, lowest first.
- Every row comes back, whether the person can use it or not. A revoked membership, or a membership of a disabled company, is in the list. Its
- Answers
200with the list.- A person with no
Org::Membershipgets"org_memberships": []and200, not an error.
- A person with no
Two checks that other endpoints make do not run here:
X-CSRF-Token. AGETchanges no data, so Rails does not ask for the header.- An active membership. This endpoint needs none. The employer endpoints do: see JodApp Web — Employer endpoints require an active membership.
A request, shortened:
GET /identities/users/current/org_memberships HTTP/1.1
Host: api.jodapp.com
Cookie: jodapp_session_id=<signed session id>
HTTP/1.1 200 OK
Cache-Control: no-store
{
"org_memberships": [
{
"id": 184,
"company_id": 27,
"role": "hq_manager",
"access_status": "active",
"is_selected": true,
"company": {
"id": 27,
"name": "Hanbaobao Pte Ltd",
"careers_access_status": "allowed",
"careers_trial_ends_at": null,
"logo_url": "https://d1abc.cloudfront.net/org/companies/9f2a/logo.png",
"timezone": "Asia/Singapore",
"is_careers_feature_enabled": true
}
},
{
"id": 201,
"company_id": 33,
"role": "outlet_manager",
"access_status": "company_disabled",
"is_selected": false,
"company": {
"id": 33,
"name": "Sunrise Bakery",
"careers_access_status": "trial",
"careers_trial_ends_at": "2026-08-31T15:59:59.000Z",
"logo_url": null,
"timezone": "Asia/Singapore",
"is_careers_feature_enabled": false
}
}
]
}
Each entry of org_memberships:
| Field | Where the value comes from |
|---|---|
id | org_memberships.id |
company_id | org_memberships.company_id |
role | org_memberships.role: :hq_manager, :area_manager or :outlet_manager |
access_status | Computed by Org::Membership#access_status from two columns. See the next table |
is_selected | org_memberships.is_selected. true on the one membership Rails acts as, false on every other. When the person has no usable membership, every entry is false. Org D1 — is_selected always marks the membership Rails acts as keeps it right |
company | The membership's Org::Company. See the company table below |
access_status is not a column. org_memberships.status says only whether the person is still part of the company. access_status says whether the person may act through this membership right now:
org_memberships.status | org_companies.status | access_status |
|---|---|---|
:active | :active | "active" |
:revoked | any value | "revoked" |
:active | any value but :active | "company_disabled" |
"revoked" wins over a disabled company. The company removed the person, so the state of that company no longer matters for them.
The company inside each entry:
| Field | Where the value comes from |
|---|---|
id | org_companies.id |
name | org_companies.name |
careers_access_status | org_companies.careers_access_status: :trial, :allowed or :blocked |
careers_trial_ends_at | org_companies.careers_trial_ends_at, as an ISO 8601 time in UTC, or null |
logo_url | Computed. There is no logo_url column.null when org_companies.logo_s3_path is empty.Otherwise https://<cloudfront domain>/<logo_s3_path>. The domain is aws.cloudfront_domain in the Rails credentials |
timezone | Computed. The timezone column of the company's geo_areas row, found through org_companies.address_geo_area_id. An IANA name, such as Asia/Singapore |
is_careers_feature_enabled | Computed by Org::Company#careers_feature_enabled?. See the next table |
is_careers_feature_enabled says whether the company's careers feature is on right now:
careers_access_status | careers_trial_ends_at | is_careers_feature_enabled |
|---|---|---|
:allowed | any value | true |
:blocked | any value | false |
:trial | null | true |
:trial | a time still ahead | true |
:trial | a time already passed | false |
timezone and is_careers_feature_enabled use the same code as Org::EmployersCompanyEmbeddedSerializer. So both serializers answer the same two values for the same company.
This endpoint answers 401 with code unauthorized in these cases, and no other:
| When | Why |
|---|---|
No jodapp_session_id cookie | Nobody is signed in. An admin's teamjod_session_id cookie counts as no cookie, because a user endpoint never reads it |
| The cookie's signature fails | The cookie was changed, so Rails treats it as missing |
The Identities::UserSession is gone, or it has passed a limit | The session ended |
The Identities::User has is_deleted: true | The account is gone |
This endpoint never answers 403, because neither the CSRF check nor the membership check runs here.
What can go wrong
Every error is JSON with a status and a code.
- The status says what kind of failure it is. The code says which one.
422is also the status of every validation failure on other endpoints, and403is also the status of a refused permission, so the status alone is not enough.
- A client decides what to do from the code, never from the message text.
- The message is for a person to read and may change its wording. The code is a contract and does not change.
- Tests on both sides assert on the code.
| Status | Code | When |
|---|---|---|
401 | unauthorized | No session cookie or token was sent, or the Identities::UserSession is gone, or it has expired. The client is signed out |
403 | csrf_token_missing | A cookie-authenticated request that changes data has no X-CSRF-Token header |
403 | csrf_token_invalid | The header is present but does not match csrf_token on the Identities::UserSession |
422 | invalid_login | Wrong identifier or password, or a deleted account |
422 | verification_required | The account has neither is_email_verified nor is_phone_verified. The client sends the person to the OTP that verifies them |
422 | credential_not_allowed | A browser origin asked for a bearer token |
429 | rate_limited | More than 10 login attempts in 3 minutes from one IP address |
A 403 never signs anyone out.
- The session is fine. Only the header is wrong, and the fix is in the client's code.