Page Session Protection
Overview
Page session protection addresses a critical security issue in web applications that support authentication and multi-account workflows: session boundary problems. These occur when actions are performed in the wrong user context, leading to serious security and usability risks such as accidental credential linking or unauthorized account access.
This chapter explains the session boundary attack vectors and how the oauth2-passkey library protects against them using page session tokens.
Session Boundary Attacks
Modern web applications face two common session boundary problems:
Page-to-Request Desynchronization
A user loads a page while logged in as Account A. Later, they log in as Account B in another tab or window. If they return to the original page and perform an action (e.g., add credentials), that action may be executed as Account B, not Account A - potentially linking credentials to the wrong user.
Concrete Scenario:
- A user views their account page (User A) with an “Add OAuth2 Account” button
- The user opens another tab and logs in as a different user (User B)
- The user returns to the first tab (still showing User A’s page)
- The user clicks “Add OAuth2 Account”, expecting to add the account to User A
- The OAuth2 account gets added to User B instead, because that’s the active session
Consequences:
- Users could accidentally link their Google/OAuth2 accounts to the wrong user account
- Users might not notice the mistake until much later
- Recovering from this mistake requires manual intervention
Process Start-to-Completion Desynchronization
In multi-step processes (such as passkey or OAuth2 registration), a user might start the process with one account but complete it after switching sessions. This can result in credentials being registered to an unintended user or session.
Page Session Token Mechanism
The library implements Page Session Tokens to solve the page-to-request desynchronization problem, particularly for OAuth2 account linking where standard CSRF protection is insufficient due to redirects to third-party providers.
How It Works
- When rendering the user account page, a token is generated and derived from the user’s CSRF token
- This token is embedded in the page as a JavaScript constant:
PAGE_SESSION_TOKEN - When the user clicks “Add OAuth2 Account”, this token is included in the OAuth2 authorization request
- Before redirecting to the OAuth2 provider, the system verifies that this token matches the current session
This creates a binding between the specific page the user is viewing and their current session, preventing session boundary confusion.
Embedding in the User Interface
The token is included as a JavaScript constant when rendering the user’s account page:
<script>
// Page session token for session boundary protection
const PAGE_SESSION_TOKEN = "{{ page_session_token }}";
</script>
And used in the OAuth2 account addition button:
<button onclick="oauth2.openPopup('add_to_user', PAGE_SESSION_TOKEN)" class="action-button">
Add New OAuth2 Account
</button>
Token Generation and Verification
Token Generation
The page session token is generated by applying HMAC-SHA256 to the user’s CSRF token, creating a secure derivative that cannot be reverse-engineered:
#![allow(unused)]
fn main() {
pub fn generate_page_session_token(token: &str) -> String {
let mut mac =
HmacSha256::new_from_slice(&AUTH_SERVER_SECRET).expect("HMAC can take key of any size");
mac.update(token.as_bytes());
let result = mac.finalize().into_bytes();
URL_SAFE_NO_PAD.encode(result)
}
}
Token Verification
The verification happens in the OAuth2 handler before redirecting to the provider:
#![allow(unused)]
fn main() {
async fn google_auth(
auth_user: Option<AuthUser>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Result<(HeaderMap, Redirect), (StatusCode, String)> {
let mode = params.get("mode").cloned();
let context = params.get("context").cloned();
if mode.is_some() && mode.as_ref().unwrap() == "add_to_user" {
if context.is_none() {
return Err((StatusCode::BAD_REQUEST, "Missing Context".to_string()));
}
if auth_user.is_none() {
return Err((StatusCode::BAD_REQUEST, "Missing Session".to_string()));
}
// Verify that the token matches the current session
verify_page_session_token(&headers, Some(&context.unwrap()))
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
}
// If verification passes, proceed with OAuth2 flow
// ...
}
}
The verification function checks that the token matches what is expected for the current session:
#![allow(unused)]
fn main() {
pub async fn verify_page_session_token(
headers: &HeaderMap,
page_session_token: Option<&String>,
) -> Result<(), SessionError> {
let session_id: &str = match get_session_id_from_headers(headers) {
Ok(Some(session_id)) => session_id,
_ => {
return Err(SessionError::PageSessionToken(
"Session ID missing".to_string(),
));
}
};
let cached_session = GENERIC_CACHE_STORE
.lock()
.await
.get("session", session_id)
.await
.map_err(|e| SessionError::Storage(e.to_string()))?
.ok_or(SessionError::SessionError)?;
let stored_session: StoredSession = cached_session.try_into()?;
match page_session_token {
Some(context) => {
if context.as_str() != generate_page_session_token(&stored_session.csrf_token) {
tracing::error!("Page session token does not match session user");
return Err(SessionError::PageSessionToken(
"Page session token does not match session user".to_string(),
));
}
}
None => {
tracing::error!("Page session token missing");
return Err(SessionError::PageSessionToken(
"Page session token missing".to_string(),
));
}
}
Ok(())
}
}
Why This Works
- The page session token is derived from the CSRF token of the user who was logged in when the page was loaded
- If the user’s session changes (by logging out and in as another user), the CSRF token in the new session will be different
- When the page session token is verified, it will not match what is expected for the current session
- The OAuth2 flow is rejected before it even starts, preventing incorrect account linkage
Integration with OAuth2 Flows
Session Context Preservation
For the OAuth2 flow itself, the library maintains session continuity through the entire process:
1. Storing Session Context at Flow Start:
#![allow(unused)]
fn main() {
// Store the current session ID in cache when starting OAuth2 flow
let misc_id = if let Some(session_id) = get_session_id_from_headers(&headers)? {
Some(store_token_in_cache("misc_session", session_id, ttl, expires_at, None).await?)
} else {
None
};
// Include the misc_id in the state parameter
let state_params = StateParams {
csrf_id,
nonce_id,
pkce_id,
misc_id, // Reference to stored session ID
mode_id,
};
}
2. Retrieving Session Context at Flow Completion:
#![allow(unused)]
fn main() {
pub(crate) async fn get_uid_from_stored_session_by_state_param(
state_params: &StateParams,
) -> Result<Option<SessionUser>, OAuth2Error> {
let Some(misc_id) = &state_params.misc_id else {
return Ok(None);
};
// Get the session ID that was stored at the beginning of the flow
let Ok(token) = get_token_from_store::<StoredToken>("misc_session", misc_id).await else {
return Ok(None);
};
// Retrieve the user from that original session
match get_user_from_session(&token.token).await {
Ok(user) => Ok(Some(user)),
Err(_) => Ok(None)
}
}
}
3. Using Preserved Context for Account Linking:
#![allow(unused)]
fn main() {
// During OAuth2 account linking process
let state_in_response = decode_state(&auth_response.state)?;
let state_user = get_uid_from_stored_session_by_state_param(&state_in_response).await?;
// Link the OAuth2 account to the original user who initiated the flow
if let Some(user) = state_user {
// Account linking uses the preserved user context
}
}
This approach ensures that:
- The OAuth2 account is always linked to the user who initiated the flow
- Session changes during the OAuth2 process do not affect the final account linking
- Continuous user context is maintained from flow initiation to completion
Key Security Characteristics
Phase-Specific Protection
Each mechanism addresses a specific phase where session desynchronization can occur:
| Phase | Mechanism | Purpose |
|---|---|---|
| Page load to request | Page session tokens | Detect session changes before OAuth2 redirect |
| OAuth2 flow duration | Session context preservation | Maintain user identity through redirects |
Early Detection
Problems are caught at the earliest possible point:
- Page-level desynchronization is caught before the OAuth2 flow starts
- The user sees a clear error message about session mismatch
Minimal Implementation
- Leverages existing CSRF token mechanism
- No additional database storage required (uses existing cache)
- Single HMAC operation with negligible performance overhead
Testing Page Session Protection
You can verify this protection works by following these steps:
- Log in as User A and open their account page
- In another tab, log out and log in as User B
- Return to User A’s account page and click “Add OAuth2 Account”
- The system should display an error message about session mismatch
- The OAuth2 flow should not proceed
This confirms that the page session token mechanism successfully prevents accidental account linking when sessions change.