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?
- 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.
- Different frontend domains: The admin dashboard runs on
jodteam.com(production) while the user app runs onjodapp.com. Separate cookie names means each controller only reads its own credentials. - 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 context | Admin context | |
|---|---|---|
| Identity model | Identities::User | Identities::Admin |
| JWT payload key | { user_id: 123 } | { admin_id: 456 } |
| Access cookie | jwt_access | team_jwt_access |
| CSRF cookie | CSRF_TOKEN | TEAM_CSRF_TOKEN |
| Redis namespace | (default) | team |
| Redis keys | jwt/_access_xxx, jwt/__refresh_yyy | jwt/team_access_xxx, jwt/team__refresh_yyy |
| Login endpoint | POST /identities/sessions | POST /team/identities/admins/sessions |
| Refresh endpoint | PATCH /identities/sessions/current | PATCH /team/identities/admins/sessions/current |
| Logout endpoint | DELETE /identities/sessions/current | DELETE /team/identities/admins/sessions/current |
Three layers of isolation:
| Layer | User context | Admin context | Why it matters |
|---|---|---|---|
| Cookie name | jwt_access | team_jwt_access | Browser sends only the right cookie to the right controller. An admin cookie is never read by user controllers. |
| JWT payload key | user_id | admin_id | Even 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) | team | Sessions 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.
| Env | User frontends | Admin frontend | API | Cookie domain |
|---|---|---|---|---|
| Production | jodapp.com | jodteam.com | api.jodapp.com | .jodapp.com |
| QA | jodapp.dev | jodapp.dev/team | api.jodapp.dev | .jodapp.dev |
| Local | localhost:5173 | localhost:5173/team | localhost: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.
Cookie settings (both contexts)
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.
cookie_domain method (ApplicationController)
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
| Controller | Auth method | Cookie read | JWT payload key | Sets on CurrentRequest |
|---|---|---|---|---|
Identities::Users::AuthenticatedController | authenticate() | jwt_access | user_id | identities_user |
Employers::BaseController | authorize_org_user() | (inherited) | user_id | org_user_profile, org_company |
Talent::BaseController | authenticate_talent_profile() | (inherited) | user_id | talent_profile |
Candidates::BaseController | authenticate_talent_profile() | (inherited) | user_id | talent_profile |
Team::AuthenticatedController | authenticate() | team_jwt_access | admin_id | identities_admin |
Marketplace::BaseController | identify_current_user() | jwt_access (optional) | user_id (optional) | identities_user (or nil) |
Identities::SessionsController | (login controller) | jwt_access | user_id | — |
Team::Identities::Admins::SessionsController | (login controller) | team_jwt_access | admin_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 type | Session controller | Login endpoint | What it creates |
|---|---|---|---|
Identities::User | Identities::SessionsController | POST /identities/sessions | JWT session with user_id payload |
Identities::Admin | Team::Identities::Admins::SessionsController | POST /team/identities/admins/sessions | JWT 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:
| Controller | before_action | Question it answers |
|---|---|---|
Identities::Users::AuthenticatedController | authenticate | Is there a valid Identities::User? |
Employers::BaseController | authorize_org_user | Does this user have an employer profile? |
Talent::BaseController | authenticate_talent_profile | Does this user have a talent profile? |
Team::AuthenticatedController | authenticate | Is there a valid Identities::Admin? |
Marketplace::BaseController | identify_current_user | Is 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
| Resource | Database Model? | Actions |
|---|---|---|
| Identities::User | Yes (identities_users table) | Create, Read, Update, Delete |
| Careers::Job | Yes (careers_jobs table) | Create, Read, Update, Delete |
| Session | No (JWT in Redis) | Create (login), Refresh, Destroy (logout) |
| Search Results | No | Read (query) |
| Report | No | Create (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:
| Action | Resource Perspective | Implementation | HTTP Method + Endpoint |
|---|---|---|---|
| Login | Create a session | Issue JWT tokens | POST /sessions |
| Refresh | Renew current session | Issue new access token | PATCH /sessions/current |
| Logout | Destroy current session | Invalidate JWT in Redis | DELETE /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):
-
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! -
Logout destroys by refresh UID
def destroy
# We identify and destroy THE session by refresh token UID
session.flush_by_access_payload()
endIf each refresh created a new session, we couldn't destroy "the" session with a single UID.
-
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):
| Setting | Value | Meaning |
|---|---|---|
| Algorithm | HS256 | Symmetric signing (same key for sign + verify) |
| Signing key | Rails.application.credentials.secret_key_base | Stored in encrypted credentials, never in code |
| Access token TTL | 1 hour | Short-lived credential, frequent refresh |
| Refresh token TTL | 7 days | Session lifetime, stored in Redis |
| Token store | Redis (prod/staging), Memory (dev) | Redis provides persistence across deploys |
refresh_by_access_allowed | true | Links refresh token to access token — only 2 cookies needed instead of 3 |
CSRF Protection
The jwt_sessions gem provides built-in CSRF protection:
- On login, the gem generates a CSRF token tied to the access token
- We store it in a JS-readable cookie (
CSRF_TOKEN/TEAM_CSRF_TOKEN) - The frontend reads this cookie and sends it back as
X-CSRF-Tokenheader - 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_accessandteam_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/sessionsresource 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
- Rails Routing Guide
- RESTful Web Services by Leonard Richardson
- Domain-Driven Design by Martin Fowler
- OWASP Session Management Cheat Sheet