Skip to main content

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 doesUsersAdmins
Login: create a sessionPOST /identities/user_sessionsPOST /team/identities/admin_sessions
Read the current sessionGET /identities/user_sessions/currentGET /team/identities/admin_sessions/current
Logout: end the current sessionDELETE /identities/user_sessions/currentDELETE /team/identities/admin_sessions/current
Sign out everywhere: end every session of this personDELETE /identities/user_sessionsDELETE /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_id and TEAM_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:

FieldUsersAdmins
identifierThe email address or the mobile numbernot used
emailnot usedThe email address
passwordThe passwordThe password
credential:cookie, the default, or :token for the mobile appThe same

What Rails does, in order:

  1. Counts login attempts from the caller's IP address.
  2. Finds the person and checks the password.
    • has_secure_password compares in constant time.
    • A wrong identifier, a wrong password, or a deleted account all answer the same 422 with code invalid_login.
    • An unverified account answers 422 with code verification_required instead, so the client can send the person to the OTP that verifies them.
      • An unverified account is one where is_email_verified and is_phone_verified are 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_verified and sets is_email_verified: false.
      • The ruling is Identities D5, the row "An unverified account at login".
    • The message is generic. The response never says which part was wrong, so it never reveals that an account exists.
  3. Creates one Identities::UserSession.
    • It holds the owner, a new uuid, a random csrf_token, and the caller's ip_address and user_agent.
    • It also sets last_login on the person's own record, as today.
    • A token login also stores the token's SHA-256 digest in token_digest.
  4. Sets the credential.
  5. Answers 200 with the current-session JSON.
    • mobile: token is added at the top level, once. Rails never shows the token again.

A browser origin that asks for credential: :token is refused.

  • Rails answers 422 with code credential_not_allowed.
  • A bearer token in a browser would be readable by scripts, which is what the httponly cookie exists to prevent.
  • Until the mobile app's first issue builds the token path, every login that asks for credential: :token is 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:

  1. Finds the Identities::UserSession.
    • web: a jodapp_session_id cookie gives the session's id, after the signature check.
    • mobile: an Authorization: Bearer header gives a token, and its SHA-256 digest finds the Identities::UserSession through token_digest.
    • A request with neither answers 401.
  2. Checks the Identities::UserSession is not expired.
    • updated_at must be within the idle limit and created_at within the absolute limit.
    • An expired session answers 401, the same as a missing one.
  3. Touches updated_at, but only when it is older than 15 minutes.
    • Most reads write nothing.
  4. Answers 200 with the current-session JSON.
    • The response carries Cache-Control: no-store.
    • The response never sets a cookie.

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" }
}

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 a DELETE changes data
  • the mobile app, from its sign-out button

What Rails does, in order:

  1. Finds the Identities::UserSession, as in "Read the current session".
    • No valid session answers 401, and the client treats that as already signed out.
  2. Destroys the Identities::UserSession.
  3. 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:

  1. Finds the Identities::UserSession, as in "Read the current session".
  2. Destroys every Identities::UserSession of the same owner, the current one included.
  3. Answers 204.
    • web: both cookies are deleted in the response.
    • Every other browser and device of that person gets 401 on its next request.

Other endpoints that end sessions​

EndpointWhat it does to sessionsWhy
The password reset: POST /identities/users/password/forgot sends an OTP, then PATCH /identities/users/password sets the new password with itDestroys every Identities::UserSession of that personThe 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 emailDestroys every Identities::AdminSession of that adminThe 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 stepThe 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::Membership connects one Identities::User to one Org::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.
  • Admins have no Org::Membership, so this endpoint has no admin twin.

Who calls it:

  • the JodApp Web server, from the loader of app/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 loader runs 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 loader keeps one entry: the one with is_selected: true.
    • When no entry has is_selected: true, the loader keeps none and answers { orgMembership: null }. The pages inside the employer area then show no company data. A person with no usable membership never reaches that loader, because employer-required-policy sends them to /employers/access-unavailable first.

What Rails does, in order:

  1. Sets Cache-Control: no-store on the response.
    • This runs before the session check, so a 401 carries the header too.
    • The list belongs to one signed-in person, so no browser or proxy may store it.
  2. Finds the Identities::UserSession from the jodapp_session_id cookie, 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_at only when it is older than 15 minutes.
  3. Refuses a deleted person.
    • An Identities::User with is_deleted: true answers 401.
  4. Loads every Org::Membership whose user_id is the person's id, 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_status says why the person cannot use it.
    • The query also loads each company and each company's geo_areas row, 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.id decides, lowest first.
  5. Answers 200 with the list.
    • A person with no Org::Membership gets "org_memberships": [] and 200, not an error.

Two checks that other endpoints make do not run here:

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:

FieldWhere the value comes from
idorg_memberships.id
company_idorg_memberships.company_id
roleorg_memberships.role: :hq_manager, :area_manager or :outlet_manager
access_statusComputed by Org::Membership#access_status from two columns. See the next table
is_selectedorg_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
companyThe 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.statusorg_companies.statusaccess_status
:active:active"active"
:revokedany value"revoked"
:activeany 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:

FieldWhere the value comes from
idorg_companies.id
nameorg_companies.name
careers_access_statusorg_companies.careers_access_status: :trial, :allowed or :blocked
careers_trial_ends_atorg_companies.careers_trial_ends_at, as an ISO 8601 time in UTC, or null
logo_urlComputed. 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
timezoneComputed. 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_enabledComputed 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_statuscareers_trial_ends_atis_careers_feature_enabled
:allowedany valuetrue
:blockedany valuefalse
:trialnulltrue
:triala time still aheadtrue
:triala time already passedfalse

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:

WhenWhy
No jodapp_session_id cookieNobody 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 failsThe cookie was changed, so Rails treats it as missing
The Identities::UserSession is gone, or it has passed a limitThe session ended
The Identities::User has is_deleted: trueThe 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.
    • 422 is also the status of every validation failure on other endpoints, and 403 is 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.
StatusCodeWhen
401unauthorizedNo session cookie or token was sent, or the Identities::UserSession is gone, or it has expired. The client is signed out
403csrf_token_missingA cookie-authenticated request that changes data has no X-CSRF-Token header
403csrf_token_invalidThe header is present but does not match csrf_token on the Identities::UserSession
422invalid_loginWrong identifier or password, or a deleted account
422verification_requiredThe account has neither is_email_verified nor is_phone_verified. The client sends the person to the OTP that verifies them
422credential_not_allowedA browser origin asked for a bearer token
429rate_limitedMore 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.