Skip to main content

Authentication Architecture

  • Reviewed by Ali
  • Updated: Nov 2025 - RESTful session management
  • Updated: Feb 2026 - Two-context auth architecture (User + Admin), cookie isolation, SessionCookieManager refactor

The Big Picture

Our API serves 4 frontends through 2 completely separate authentication contexts. This is the single most important concept to understand.

Key takeaway: Identities::User and Identities::Admin are completely separate identity types. They have separate database tables, separate JWT cookies, separate Redis namespaces, and separate login endpoints. They never overlap.


Two Isolated Session Contexts

The system maintains two completely isolated authentication contexts. This is a deliberate security decision, not an accident.

Why isolate?

  1. Different trust levels: Admins can modify billing, jobs, and user data. Users can only modify their own profile. Mixing them in a single cookie means a bug in user auth could escalate to admin access.
  2. Different frontend domains: The admin dashboard runs on jodteam.com (production) while the user app runs on jodapp.com. Separate cookie names means each controller only reads its own credentials.
  3. Independent session lifecycles: Logging out of the admin dashboard does not log you out of the user app (and vice versa). This is correct behavior — they are different systems.

How isolation works

User contextAdmin context
Identity modelIdentities::UserIdentities::Admin
JWT payload key{ user_id: 123 }{ admin_id: 456 }
Access cookiejwt_accessteam_jwt_access
CSRF cookieCSRF_TOKENTEAM_CSRF_TOKEN
Redis namespace(default)team
Redis keysjwt/_access_xxx, jwt/__refresh_yyyjwt/team_access_xxx, jwt/team__refresh_yyy
Login endpointPOST /identities/sessionsPOST /team/identities/admins/sessions
Refresh endpointPATCH /identities/sessions/currentPATCH /team/identities/admins/sessions/current
Logout endpointDELETE /identities/sessions/currentDELETE /team/identities/admins/sessions/current

Three layers of isolation:

LayerUser contextAdmin contextWhy it matters
Cookie namejwt_accessteam_jwt_accessBrowser sends only the right cookie to the right controller. An admin cookie is never read by user controllers.
JWT payload keyuser_idadmin_idEven if a cookie were somehow misrouted, the controller would reject it — it looks for admin_id and finds user_id (or vice versa).
Redis namespace(default)teamSessions are stored in separate Redis keyspaces. Flushing all admin sessions does not affect user sessions.

Domain layout

All frontends hit the same API. The cookie domain is always the API's parent domain, not the frontend domain.

EnvUser frontendsAdmin frontendAPICookie domain
Productionjodapp.comjodteam.comapi.jodapp.com.jodapp.com
QAjodapp.devjodapp.dev/teamapi.jodapp.dev.jodapp.dev
Locallocalhost:5173localhost:5173/teamlocalhost:3000:all

Important: Both user AND admin cookies use .jodapp.com as the domain — even though the admin frontend is on jodteam.com. This is because the API at api.jodapp.com can only set cookies for its own domain (.jodapp.com). It cannot set cookies for .jodteam.com. Cookie isolation is via cookie names, not cookie domains.

When jodteam.com makes a cross-origin request to api.jodapp.com, the browser sends .jodapp.com cookies because the destination matches. The origin (jodteam.com) is handled by CORS, not cookie domain.

Both contexts use identical cookie security settings:

{
http_only: true, # JS cannot read the access token (XSS protection)
secure: !Rails.env.development?, # HTTPS in production + QA
same_site: Rails.env.development? ? :lax : :none, # Cross-origin in production + QA
path: '/', # Available on all API paths
domain: cookie_domain(), # .jodapp.com (prod), .jodapp.dev (qa), :all (dev)
expires: 7.days # Matches refresh token lifetime
}

The CSRF cookie is the exception — it uses http_only: false so the frontend JavaScript can read it and send it back as a header (X-CSRF-Token). This is the standard double-submit cookie pattern for CSRF protection.

Defined once in ApplicationController as a private method, inherited by all controllers:

def cookie_domain
{
development: :all,
qa: '.jodapp.dev',
production: '.jodapp.com'
}.fetch(Rails.env.to_sym, :all)
end

Controller Hierarchy

Controller responsibility summary

ControllerAuth methodCookie readJWT payload keySets on CurrentRequest
Identities::Users::AuthenticatedControllerauthenticate()jwt_accessuser_ididentities_user
Employers::BaseControllerauthorize_org_user()(inherited)user_idorg_user_profile, org_company
Talent::BaseControllerauthenticate_talent_profile()(inherited)user_idtalent_profile
Candidates::BaseControllerauthenticate_talent_profile()(inherited)user_idtalent_profile
Team::AuthenticatedControllerauthenticate()team_jwt_accessadmin_ididentities_admin
Marketplace::BaseControlleridentify_current_user()jwt_access (optional)user_id (optional)identities_user (or nil)
Identities::SessionsController(login controller)jwt_accessuser_id
Team::Identities::Admins::SessionsController(login controller)team_jwt_accessadmin_id

Login Flows

User login (candidates + employers)

Admin login (team dashboard)


Authenticated Request Flows

User requests (candidates, employers, marketplace)

Admin requests (team dashboard)

Marketplace requests (optional auth)


Session Lifecycle: Refresh and Logout

Session refresh (both contexts)

The refresh flow is identical for both User and Admin — only the cookie names and Redis namespace differ.

Why PATCH, not POST? The refresh token UID stays the same. We're updating the credentials of an existing session, not creating a new one. See Session Architecture Deep Dive below for the full reasoning.

Session logout (both contexts)


Authentication vs. Authorization

Authentication (Who are you?)

Question: "Are you really who you claim to be?"

In our app, authentication is handled by two session controllers — one per identity type:

Identity typeSession controllerLogin endpointWhat it creates
Identities::UserIdentities::SessionsControllerPOST /identities/sessionsJWT session with user_id payload
Identities::AdminTeam::Identities::Admins::SessionsControllerPOST /team/identities/admins/sessionsJWT session with admin_id payload

Both follow the same pattern: verify email+password, create a JWT session in Redis, set cookies.

Authorization (What can you do?)

Question: "Are you allowed to perform this action?"

Authorization happens on every request via before_action callbacks in base controllers:

Controllerbefore_actionQuestion it answers
Identities::Users::AuthenticatedControllerauthenticateIs there a valid Identities::User?
Employers::BaseControllerauthorize_org_userDoes this user have an employer profile?
Talent::BaseControllerauthenticate_talent_profileDoes this user have a talent profile?
Team::AuthenticatedControllerauthenticateIs there a valid Identities::Admin?
Marketplace::BaseControlleridentify_current_userIs a user logged in? (optional)

Key insight: Authentication happens once (at login). Authorization happens on every request.


First Principles: What is a Resource?

In REST architecture, a resource is any conceptual entity that can be:

  • Created
  • Read
  • Updated
  • Deleted (CRUD)

Critically important: Resources are NOT limited to database models.

Examples of Resources

ResourceDatabase Model?Actions
Identities::UserYes (identities_users table)Create, Read, Update, Delete
Careers::JobYes (careers_jobs table)Create, Read, Update, Delete
SessionNo (JWT in Redis)Create (login), Refresh, Destroy (logout)
Search ResultsNoRead (query)
ReportNoCreate (generate)

Authentication as Session Management

When a user logs in, what are we really doing?

Wrong Mental Model

User logs in -> We create a JWT token -> Return token

Problem: This is thinking in implementation details, not resources.

Correct Mental Model

User requests access -> We create a SESSION resource -> Session represented as JWT

Why this matters:

  • REST is about what (session), not how (JWT)
  • Tomorrow you might switch from JWT to session cookies or OAuth tokens
  • The resource (session) remains constant; the implementation can change

Why SessionsController, not LoginsController?

Sessions are the resource. Consider the actions:

ActionResource PerspectiveImplementationHTTP Method + Endpoint
LoginCreate a sessionIssue JWT tokensPOST /sessions
RefreshRenew current sessionIssue new access tokenPATCH /sessions/current
LogoutDestroy current sessionInvalidate JWT in RedisDELETE /sessions/current

"Login" is a verb (action), not a noun (resource). In REST, controllers represent resources (nouns), and HTTP methods (POST, PATCH, DELETE) represent actions.

Our RESTful Session Routes

We use pure RESTful routing for session management:

# User sessions: config/routes/identities_routes.rb
namespace :identities do
namespace :sessions do
post '', controller: '/identities/sessions', action: 'create' # Login
patch 'current', controller: '/identities/sessions', action: 'update' # Refresh
delete 'current', controller: '/identities/sessions', action: 'destroy' # Logout
end
end

# Admin sessions: config/routes/team_routes.rb
namespace :team do
namespace :identities do
namespace :admins do
namespace :sessions do
post '', controller: '/team/identities/admins/sessions', action: 'create' # Login
patch 'current', controller: '/team/identities/admins/sessions', action: 'update' # Refresh
delete 'current', controller: '/team/identities/admins/sessions', action: 'destroy' # Logout
end
end
end
end

Session Architecture Deep Dive

What IS a Session?

In our system, a session is identified by the refresh token's UID, not the access token.

Session Lifecycle

+-------------------------------------------------------+
| SESSION (Refresh Token UID: abc-123) |
| =====================================================|
| Created: 2025-11-05 10:00:00 |
| Expires: 2025-11-12 10:00:00 (7 days) |
| User: john@example.com |
| |
| Credentials (Renewable): |
| +-----------------------------------------------------+
| | Access Token #1 (UID: xyz-789) | |
| | Valid: 10:00 - 11:00 (1 hour) | |
| | CSRF: token_abc | |
| +-----------------------------------------------------+
| |
| After Refresh (PATCH /sessions/current): |
| +-----------------------------------------------------+
| | Access Token #2 (UID: def-456) | |
| | Valid: 11:00 - 12:00 (1 hour) | |
| | CSRF: token_xyz | |
| +-----------------------------------------------------+
| |
| Logout (DELETE /sessions/current): |
| Session destroyed by refresh UID: abc-123 |
+-------------------------------------------------------+

Redis Keys Explained

When you login, create:

Redis Keys:
jwt/__refresh_abc-123 <-- Session identifier (stays for 7 days)
jwt/_access_xyz-789 <-- Access credential (expires in 1 hour)

When you refresh, update:

Redis Keys:
jwt/__refresh_abc-123 <-- UNCHANGED (same session)
jwt/_access_def-456 <-- NEW (new credential)

For admin sessions with namespace team:

Redis Keys:
jwt/team__refresh_abc-123 <-- Namespaced, isolated from user sessions
jwt/team_access_def-456 <-- Namespaced

Key insight: The refresh token UID (abc-123) never changes during refresh. This proves we're updating the same session, not creating a new one.

Why PATCH, Not POST?

The HTTP verb should match the semantic operation:

Evidence that it's an UPDATE (PATCH):

  1. Refresh token UID doesn't change

    # Before refresh
    refresh_token_uid: "5d4b2869-456f-4a80-ba46-7ead2a3f19c1"

    # After refresh
    refresh_token_uid: "5d4b2869-456f-4a80-ba46-7ead2a3f19c1" # SAME!
  2. Logout destroys by refresh UID

    def destroy
    # We identify and destroy THE session by refresh token UID
    session.flush_by_access_payload()
    end

    If each refresh created a new session, we couldn't destroy "the" session with a single UID.

  3. Only credentials change, not session identity

    Session Identity (Refresh UID): abc-123 -> abc-123 (SAME)
    Access Token UID: xyz-789 -> def-456 (DIFFERENT)
    CSRF Token: token_abc -> token_xyz (DIFFERENT)

Analogy: Hotel Booking

  • Your booking (session) = Refresh token UID
  • Your key card (access credential) = Access token
  • Getting a new key card does not mean making a new booking
  • You update your access to the same booking (PATCH)
  • You don't create a new booking (POST)

Common Misconceptions

Misconception 1: "Redis key changes = new resource"

Wrong: The access token's Redis key changing is an implementation detail.

Right: The session is identified by the refresh token UID, which doesn't change.

Example: When your bank generates a new temporary password, that doesn't mean you have a new bank account. The account (session) is the same; the credential (password/token) is different.

Misconception 2: "POST means creating anything"

Wrong: POST should create a new resource, not just new data.

Right: We're not creating a new session resource (refresh UID proves this). We're updating the session's access credentials.

Misconception 3: "Refresh creates a new access token, so use POST"

Context matters: If we treated access tokens as standalone resources, then yes, POST /identities/sessions/access_tokens would be correct.

Our model: We treat the session as the resource. Access tokens are attributes of the session (like updating a user's password is PATCH, not POST).


JWT Configuration

Reference for the jwt_sessions gem setup (config/initializers/jwt_sessions.rb):

SettingValueMeaning
AlgorithmHS256Symmetric signing (same key for sign + verify)
Signing keyRails.application.credentials.secret_key_baseStored in encrypted credentials, never in code
Access token TTL1 hourShort-lived credential, frequent refresh
Refresh token TTL7 daysSession lifetime, stored in Redis
Token storeRedis (prod/staging), Memory (dev)Redis provides persistence across deploys
refresh_by_access_allowedtrueLinks refresh token to access token — only 2 cookies needed instead of 3

CSRF Protection

The jwt_sessions gem provides built-in CSRF protection:

  1. On login, the gem generates a CSRF token tied to the access token
  2. We store it in a JS-readable cookie (CSRF_TOKEN / TEAM_CSRF_TOKEN)
  3. The frontend reads this cookie and sends it back as X-CSRF-Token header
  4. The gem validates the header matches the token in the JWT

This is the double-submit cookie pattern — the browser automatically sends the cookie, but an attacker on a different origin cannot read the cookie value to put it in the header.


Common Patterns

Pattern 1: Resource-Based Controllers

When your endpoint manages a clear resource:

class JobsController
def index # GET /jobs
def show # GET /jobs/:id
def create # POST /jobs
def update # PATCH /jobs/:id
def destroy # DELETE /jobs/:id
end

Pattern 2: Service-Based Controllers (For Non-CRUD Operations)

When your endpoint performs an operation that doesn't fit CRUD:

class Jobs::PublishController
def create # POST /jobs/:id/publish
# Publishing is an action on a job, but not a standard CRUD operation
end
end

Pattern 3: Session Controllers (Our Case)

Session is a resource, represented by JWT tokens:

class Identities::SessionsController
def create # POST /identities/sessions (login)
def update # PATCH /identities/sessions/current (refresh)
def destroy # DELETE /identities/sessions/current (logout)
end

Note: We use standard REST action names (create, update, destroy) not custom action names (login, refresh, logout).


Testing Your Understanding

Question 1

You need to add a "password reset" feature. Should it be:

  • A) PasswordResetsController
  • B) PasswordsController
  • C) Either, depending on how you think about the resource

Answer: C, but B is preferred. You're resetting a PASSWORD resource. The controller could have a reset action or a custom endpoint.

Question 2

You need an endpoint to generate a PDF report of job applications. Should it be:

  • A) PdfController#generate
  • B) ReportsController#create
  • C) JobApplications::ReportsController#create

Answer: C. Reports are resources scoped to job applications. You're CREATING a report resource.

Question 3

Your DELETE /identities/sessions/current endpoint should be:

  • A) LogoutsController#create
  • B) SessionsController#logout
  • C) SessionsController#destroy

Answer: C. We use standard REST actions. The controller action destroy handles DELETE /identities/sessions/current. This is pure RESTful design where the HTTP method (DELETE) indicates the action, and the controller action follows Rails conventions.

Question 4

An admin logs in on jodteam.com and a user logs in on jodapp.com. Both hit api.jodapp.com. Which cookies does the browser send for an admin request?

  • A) Both jwt_access and team_jwt_access
  • B) Only team_jwt_access
  • C) Only jwt_access

Answer: A — because both cookies have domain: .jodapp.com, the browser sends both. But Team::AuthenticatedController only reads team_jwt_access (via the token_from_cookies override), so the user cookie is ignored. This is why cookie naming isolation matters.


SessionCookieManager Concern

Cookie I/O is centralized in a single SessionCookieManager concern (app/controllers/concerns/session_cookie_manager.rb), included by both auth base controllers. It provides two private methods parameterized by cookie names:

# Sets both access cookie (http_only: true) and CSRF cookie (http_only: false)
set_session_cookies(token:, access_cookie_name:, csrf_cookie_name:)

# Deletes both cookies
remove_session_cookies(access_cookie_name:, csrf_cookie_name:)

This eliminates the 8+ inline cookie-setting blocks and 5+ cookie-deletion blocks that previously existed across controllers.


Practical Guidelines

DO:

  • Think in resources (nouns), not actions (verbs)
  • Name controllers after the resource they manage
  • Use standard REST actions (create, update, destroy) over custom actions
  • Use proper HTTP verbs (POST for create, PATCH for update, DELETE for destroy)
  • Use singleton pattern (/current) when operating on a resource identified by context (cookies, session)
  • Keep User and Admin session contexts fully isolated (separate cookies, namespaces, payload keys)

DON'T:

  • Name controllers after actions (LoginsController, RefreshesController)
  • Name routes with verbs (/login, /refresh, /logout - use /sessions resource instead)
  • Use POST for operations that update existing resources (use PATCH)
  • Mix authentication and authorization concepts
  • Read user cookies in admin controllers (or vice versa)
  • Share a Redis namespace between user and admin sessions

Further Reading