Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0db6a7b
feat: Implement session-cookie API authentication hardening
ErykKul Feb 27, 2026
acb2c6e
Merge branch 'develop' into 12178_CSRF_session_cookie_CSRF_protections
ErykKul Feb 27, 2026
966ec7c
docs: add session-cookie hardening guidance and verification steps
ErykKul Feb 27, 2026
e8dca34
docs: improve clarity on session-cookie hardening and bearer token au…
ErykKul Mar 18, 2026
4901a57
Merge branch 'develop' into 12178_CSRF_session_cookie_CSRF_protections
ErykKul Mar 18, 2026
a472682
docs: build fix
ErykKul Mar 18, 2026
4c95f07
Update src/main/java/edu/harvard/iq/dataverse/DataverseSession.java
ErykKul Mar 18, 2026
3ec1886
docs: update session-cookie API hardening details and CSRF validation…
ErykKul Mar 18, 2026
7681a11
docs: enhance session-cookie API hardening details and CSRF protectio…
ErykKul Mar 18, 2026
4215552
docs: add CSRF warning for session cookie API authentication feature
ErykKul Mar 18, 2026
0fbb955
Potential fix for pull request finding
ErykKul Mar 18, 2026
5ce97dd
Potential fix for pull request finding
ErykKul Mar 18, 2026
27eec9c
Potential fix for pull request finding
ErykKul Mar 18, 2026
2aadc13
Potential fix for pull request finding
ErykKul Mar 18, 2026
10846b2
feat: add CSRF protection for session cookie authentication endpoint
ErykKul Mar 18, 2026
dd1e1c7
test: add unit tests for CSRF token endpoint with session cookie auth…
ErykKul Mar 18, 2026
8dd3b11
Potential fix for pull request finding
ErykKul Mar 18, 2026
3333365
Potential fix for pull request finding
ErykKul Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions doc/release-notes/12178-session-cookie-api-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Session-cookie API authentication now has an opt-in hardening track controlled by a new feature flag: `dataverse.feature.api-session-auth-hardening` (requires `dataverse.feature.api-session-auth`).

When hardening is enabled, every API request authenticated via session cookie must include:

- A valid same-origin `Origin` or `Referer` header.
- The `X-Dataverse-CSRF-Token` header matching the token from `GET /api/users/:csrf-token`.

This applies uniformly to all HTTP methods and all API paths, except for the CSRF bootstrap endpoint (`GET /api/users/:csrf-token`), which is intentionally callable without an existing `X-Dataverse-CSRF-Token` header so clients can obtain the initial token. All subsequent session-cookie-authenticated requests must include the header. Clients not on the same origin should use bearer-token authentication instead.

Additional changes:

- Auth-mechanism-aware request tagging in the API auth flow.

A new endpoint is available for session-cookie clients to fetch the CSRF token when hardening is enabled:

- `GET /api/users/:csrf-token`

Documentation updates:

- Installation guide: feature flag behavior and deployment guidance.
- Native API guide: `GET /api/users/:csrf-token` usage and `X-Dataverse-CSRF-Token` header expectations.
35 changes: 32 additions & 3 deletions doc/sphinx-guides/source/api/native-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6159,8 +6159,38 @@ Delete a Token
In order to delete a token use::

curl -H "X-Dataverse-key:$API_TOKEN" -X DELETE "$SERVER_URL/api/users/token"



Get CSRF Token for Session-Cookie API Auth
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When both ``dataverse.feature.api-session-auth`` and
``dataverse.feature.api-session-auth-hardening`` are enabled, clients using
session-cookie API authentication can fetch a CSRF token from this endpoint::

GET /api/users/:csrf-token

This endpoint requires an authenticated session-cookie request and returns a
token that must be sent in the ``X-Dataverse-CSRF-Token`` header for all
subsequent session-cookie API requests protected by the hardening rules.
These hardened requests must also include an ``Origin`` or ``Referer`` header.

Example::

curl -b cookies.txt -H "Origin:$SERVER_URL" "$SERVER_URL/api/users/:csrf-token"

Example response::

{
"status": "OK",
"data": {
"csrfToken": "9f2a8f8c-7e1f-4bf1-8fd6-4c3e3b522f3f"
}
}

To use this token in a subsequent API request::

curl -b cookies.txt -H "Origin:$SERVER_URL" -H "X-Dataverse-CSRF-Token:$CSRF_TOKEN" -X POST "$SERVER_URL/api/datasets/$ID/actions/:publish?type=minor"


Builtin Users
-------------
Expand Down Expand Up @@ -8913,4 +8943,3 @@ A curl example listing collections:

curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/mydata/retrieve/collectionList"
curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/mydata/retrieve/collectionList?userIdentifier=anotherUser"

84 changes: 83 additions & 1 deletion doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ By default, Payara doesn't send the SameSite cookie attribute, which browsers sh
Dataverse installations are explicity set to "Lax" out of the box by the installer (in the case of a "classic" installation) or through the base image (in the case of a Docker installation). For classic, see :ref:`http.cookie-same-site-value` and :ref:`http.cookie-same-site-enabled` for how to change the values. For Docker, you must rebuild the :doc:`base image </container/base-image>`. See also Payara's `documentation <https://docs.payara.fish/community/docs/6.2024.6/Technical%20Documentation/Payara%20Server%20Documentation/General%20Administration/Administering%20HTTP%20Connectivity.html>`_ for the settings above.

To inspect cookie attributes like SameSite, you can use ``curl -s -I http://localhost:8080 | grep JSESSIONID``, for example, looking for the "Set-Cookie" header.
For session-cookie API hardening guidance (including how to verify and set ``Secure``/``HttpOnly`` for ``JSESSIONID``), see :ref:`session-cookie-hardening-guidance`.

.. _ongoing-security:

Expand Down Expand Up @@ -3933,7 +3934,88 @@ To check the status of feature flags via API, see :ref:`list-all-feature-flags`
dataverse.feature.api-session-auth
++++++++++++++++++++++++++++++++++

Enables API authentication via session cookie (JSESSIONID). **Caution: Enabling this feature flag exposes the installation to CSRF risks!** We expect this feature flag to be temporary (only used by frontend developers, see `#9063 <https://github.com/IQSS/dataverse/issues/9063>`_) and for the feature to be removed in the future.
Enables API authentication via session cookie (JSESSIONID). This is needed for some JSF/SAML-oriented integrations where bearer tokens are not used.

.. warning::

Enabling this flag without also enabling :ref:`dataverse.feature.api-session-auth-hardening` exposes the installation to CSRF risks.
Always enable both flags together in production.

By itself, this feature flag does not enable CSRF protections. For stricter protections, also enable :ref:`dataverse.feature.api-session-auth-hardening`.

.. _dataverse.feature.api-session-auth-hardening:

dataverse.feature.api-session-auth-hardening
++++++++++++++++++++++++++++++++++++++++++++

Enables additional hardening for session-cookie API usage. This flag only has an effect when ``dataverse.feature.api-session-auth`` is also enabled.
The rules are based on request authentication mechanism (session cookie), not on the identity provider used to create the session
(``builtin``, Shibboleth, OAuth, OIDC, etc.).

When enabled, Dataverse requires all API requests authenticated via session cookie (except the CSRF bootstrap endpoint) to include:

- A valid same-origin ``Origin`` or ``Referer`` header.
- The ``X-Dataverse-CSRF-Token`` header matching the token obtained from ``GET /api/users/:csrf-token``.

The only per-endpoint exception is the CSRF bootstrap call itself (``GET /api/users/:csrf-token``), which by design cannot send the token it is obtaining. All other API paths are subject to these requirements, and this applies uniformly to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.).
The simplicity is intentional: session-cookie API auth
is only used by same-origin front-end clients that always have the CSRF token available.
Some ``GET`` endpoints in the codebase have side effects, so exempting reads would leave gaps.

Clients not on the same origin should use bearer-token authentication instead.

.. _session-cookie-hardening-guidance:

Session-cookie hardening deployment guidance
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

- Use HTTPS end-to-end (or trusted TLS termination before Dataverse).
- Ensure JSESSIONID cookies are set with ``Secure`` and ``HttpOnly``.
- Use ``SameSite=Lax`` (recommended default) or ``SameSite=Strict`` if your login/redirect flow supports it.
``SameSite=Strict`` can break some cross-site IdP/login return flows.

How to verify and set ``JSESSIONID`` cookie flags (Payara)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

- Verify cookie flags from a response header:

``curl -s -I https://<your-dataverse-host>/ | grep -i "set-cookie: JSESSIONID"``

The ``Set-Cookie`` header should include ``HttpOnly``, ``Secure``, and your expected ``SameSite`` value.

- Verify current Payara virtual-server settings:

``./asadmin get "configs.config.server-config.http-service.virtual-server.*.session-cookie-http-only"``

``./asadmin get "configs.config.server-config.http-service.virtual-server.*.session-cookie-secure"``

- Set ``JSESSIONID`` flags on the default virtual server (``server``):

``./asadmin set configs.config.server-config.http-service.virtual-server.server.session-cookie-http-only=true``

``./asadmin set configs.config.server-config.http-service.virtual-server.server.session-cookie-secure=true``

- If you use SSO cookie flows (``JSESSIONIDSSO``), set those too:

``./asadmin set configs.config.server-config.http-service.virtual-server.server.sso-cookie-http-only=true``

``./asadmin set configs.config.server-config.http-service.virtual-server.server.sso-cookie-secure=true``

After changing these settings, restart Payara and re-check the response headers.

Session-Cookie Hardening vs Bearer Token Auth
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

- Session-cookie auth and bearer-token auth use different trust models. Session cookie
(``JSESSIONID``) is automatically sent by browsers, while bearer token is sent only when the
client explicitly includes it.
- Because of browser auto-send behavior, session-cookie auth requires anti-CSRF controls for
state-changing API calls.
With this hardening track enabled, Dataverse enforces Origin/Referer and CSRF token checks, which brings session-cookie browser usage into a security posture comparable to bearer for first-party, same-origin UI calls.
- Bearer remains preferable for non-browser and cross-origin API clients.
- Neither model protects against stolen credentials by itself (session hijack via stolen
``JSESSIONID`` or bearer-token theft). For both, use HTTPS, secure cookie/token handling, short
lifetimes where possible, and strong XSS prevention.

.. _dataverse.feature.api-bearer-auth:

Expand Down
22 changes: 22 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/DataverseSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.logging.Logger;
import jakarta.ejb.EJB;
import jakarta.enterprise.context.SessionScoped;
Expand Down Expand Up @@ -88,6 +89,7 @@ public void setDismissedMessages(List<BannerMessage> dismissedMessages) {
* leave the state alone (see setDebug()).
*/
private Boolean debug;
private String apiCsrfToken;

public User getUser() {
return getUser(false);
Expand Down Expand Up @@ -122,6 +124,25 @@ public User getUser(boolean lookupAuthenticatedUserAgain) {
return user;
}

/**
* Returns a CSRF token scoped to the current Dataverse session.
* The token is lazily created and reused until it is explicitly cleared.
*/
public synchronized String getOrCreateApiCsrfToken() {
if (apiCsrfToken == null || apiCsrfToken.isBlank()) {
apiCsrfToken = UUID.randomUUID().toString();
}
return apiCsrfToken;
}

public synchronized boolean matchesApiCsrfToken(String token) {
return token != null && apiCsrfToken != null && apiCsrfToken.equals(token);
}

public synchronized void clearApiCsrfToken() {
apiCsrfToken = null;
}

/**
* Sets the user and configures the session timeout.
*/
Expand All @@ -136,6 +157,7 @@ public void setUser(User aUser) {
JsfHelper.addErrorMessage(BundleUtil.getStringFromBundle("deactivated.error"));
return;
}
clearApiCsrfToken();
FacesContext context = FacesContext.getCurrentInstance();
// Log the login/logout and Change the session id if we're using the UI and have
// a session, versus an API call with no session - (i.e. /admin/submitToArchive()
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ private ApiConstants() {

// Authentication
public static final String CONTAINER_REQUEST_CONTEXT_USER = "user";
public static final String CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM = "authMechanism";
public static final String AUTH_MECHANISM_NONE = "none";
public static final String AUTH_MECHANISM_API_KEY = "apiKey";
public static final String AUTH_MECHANISM_WORKFLOW_KEY = "workflowKey";
public static final String AUTH_MECHANISM_SIGNED_URL = "signedUrl";
public static final String AUTH_MECHANISM_BEARER_TOKEN = "bearerToken";
public static final String AUTH_MECHANISM_SESSION_COOKIE = "sessionCookie";
public static final String CSRF_TOKEN_HEADER = "X-Dataverse-CSRF-Token";

// Dataset
public static final String DS_VERSION_LATEST = ":latest";
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package edu.harvard.iq.dataverse.api;

import edu.harvard.iq.dataverse.Dataverse;
import edu.harvard.iq.dataverse.DataverseSession;
import edu.harvard.iq.dataverse.api.auth.AuthRequired;
import edu.harvard.iq.dataverse.authorization.users.ApiToken;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
Expand All @@ -30,6 +31,8 @@
import edu.harvard.iq.dataverse.util.json.JsonPrinter;
import edu.harvard.iq.dataverse.util.json.JsonUtil;
import jakarta.ejb.Stateless;
import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.json.JsonArray;
import jakarta.json.JsonObject;
import jakarta.json.JsonObjectBuilder;
Expand All @@ -47,6 +50,9 @@
public class Users extends AbstractApiBean {

private static final Logger logger = Logger.getLogger(Users.class.getName());

@Inject
private DataverseSession session;

@POST
@AuthRequired
Expand Down Expand Up @@ -208,6 +214,30 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc
return ok(json(authenticatedUser));
}

@GET
@AuthRequired
@Path(":csrf-token")
public Response getSessionCsrfToken(@Context ContainerRequestContext crc) {
if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) {
return error(Response.Status.BAD_REQUEST, "Session-auth hardening is disabled.");
}
Object authMechanism = crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM);
if (!ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism)) {
return error(
Response.Status.FORBIDDEN,
"CSRF token endpoint is only available for session-cookie authentication.");
}
// AuthFilter handles authentication and Origin/Referer validation.
// The CSRF header check is skipped for this endpoint (bootstrap exception in AuthFilter)
// so the client can obtain the token before making subsequent hardened calls.
try {
getRequestAuthenticatedUserOrDie(crc);
return ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken()));
} catch (WrappedResponse wr) {
return wr.getResponse();
}
}

@POST
@AuthRequired
@Path("{identifier}/removeRoles")
Expand Down
Loading
Loading