fix(security): reject ?token= URL query parameter for authentication (#20154)

## Summary

Removes the `?token=` URL query-parameter fallback from JWT
authentication. Every authenticated route (`/graphql`, `/metadata`,
REST, etc.) used to accept a full workspace JWT in the URL alongside the
`Authorization` header. The fallback was intended for the REST API
Playground only, but it was wired into the global Passport JWT extractor
and applied to every route.

URL-borne tokens leak into:
- Server access logs (nginx / Apache / CDN / proxy / load balancer)
- Log aggregators (Datadog, CloudWatch, Loki, Sumo, …)
- Browser history (and synced across devices)
- `Referer` headers when navigating to external pages
- Browser extensions with `tabs`/`webNavigation` permissions

A leaked log line was equivalent to a leaked workspace credential for
the lifetime of the token.

## What changed

- **`jwt-wrapper.service.ts`** — `extractJwtFromRequest()` is now
header-only (`ExtractJwt.fromAuthHeaderAsBearerToken()`). No URL
fallback anywhere in the system.
- **`open-api.service.ts` / `base-schema.utils.ts`** — Dropped the
`token?: string` plumbing that propagated the URL token into the schema
description. The "Authentication" section gains a "Never put your token
in a URL" warning. The "Usage with LLMs" section is rewritten to point
at the **Twenty MCP server** (header-authenticated, exposes typed tools
— the right tool for AI agents) instead of telling users to paste
tokenized OpenAPI URLs into Cursor/ChatGPT.
- **`RestPlayground.tsx`** — Now fetches the OpenAPI schema with
`Authorization: Bearer ${playgroundApiKey}` and passes the JSON document
to Scalar via `spec.content` instead of constructing a URL with
`?token=`. Aborts in-flight fetches on unmount/key change.
- **New integration test** — Asserts `?token=` is rejected on `/rest/*`,
`/graphql`, `/metadata`, and that `/rest/open-api/core?token=` returns
the unauthenticated base schema (no workspace object paths).

## Why not keep `?token=` scoped to the OpenAPI endpoint only

The first instinct was to narrow the fallback to just
`/rest/open-api/*`, since that endpoint is what the Scalar playground
component fetches. But the same log-leakage attack still applies to that
endpoint — the workspace JWT would still sit in access logs, just from
one URL pattern instead of all of them. The cleaner long-term fix is to
remove the URL pattern entirely and let the playground fetch with a
header (Scalar supports `spec.content` natively). For LLM agent use, the
MCP server is a strictly better path — typed tools, OAuth or
header-based API key auth, no tokens in URLs anywhere.

## Not affected

File downloads at `file-url.service.ts` also use `?token=` URLs but with
separate, short-lived `FILE`-typed tokens validated by
`file-by-id.guard.ts` directly (not via `extractJwtFromRequest`). That
mechanism is scoped per-file with limited TTL and is acceptable.

## Action required for users

Anyone who previously pasted `?token=` URLs into LLM tools, scripts,
bookmarks, or shared configs should rotate their workspace API keys.
Those tokens are likely captured in server logs / chat histories
somewhere.

## Test plan

- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx typecheck twenty-front` — clean
- [x] `npx nx lint:diff-with-main twenty-server` — clean
- [x] `npx nx lint:diff-with-main twenty-front` — clean
- [x] OpenAPI utils unit tests + snapshots — 11/11 pass
- [ ] Run the new integration test against a live server: `nx run
twenty-server:test:integration:with-db-reset` and verify
`url-token-auth-rejection.integration-spec.ts` passes
- [ ] Manually open Settings → Playground → REST, confirm the schema
loads (now via Bearer header instead of `?token=` URL)
- [ ] Manually verify `POST /metadata?token=<jwt>` (no Authorization
header) returns Forbidden, and the same request with the token in the
header returns the user

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-30 17:00:26 +02:00
committed by GitHub
parent f32b03a3ec
commit b44fb1ad23
8 changed files with 142 additions and 95 deletions
@@ -1,7 +1,8 @@
import { RestPlaygroundSchemaFetchEffect } from '@/settings/playground/components/RestPlaygroundSchemaFetchEffect';
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext, lazy, Suspense } from 'react';
import { useContext, useState, lazy, Suspense } from 'react';
import { styled } from '@linaria/react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { SettingsPath } from 'twenty-shared/types';
@@ -52,50 +53,59 @@ type RestPlaygroundProps = {
export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
const { theme, colorScheme } = useContext(ThemeContext);
const playgroundApiKey = useAtomStateValue(playgroundApiKeyState);
const [specContent, setSpecContent] = useState<object | null>(null);
if (!playgroundApiKey) {
onError();
return null;
}
const fallback = (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton width="100%" height="100%" />
</SkeletonTheme>
);
return (
<StyledContainer>
<Suspense
fallback={
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton width="100%" height="100%" />
</SkeletonTheme>
}
>
<ApiReferenceReact
configuration={{
spec: {
url: `${REACT_APP_SERVER_BASE_URL}/rest/open-api/${schema}?token=${playgroundApiKey}`,
},
authentication: {
http: {
bearer: playgroundApiKey
? { token: playgroundApiKey }
: undefined,
<RestPlaygroundSchemaFetchEffect
schema={schema}
apiKey={playgroundApiKey}
onSchemaLoaded={setSpecContent}
onError={onError}
/>
{specContent === null ? (
fallback
) : (
<Suspense fallback={fallback}>
<ApiReferenceReact
configuration={{
spec: {
content: specContent,
},
},
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
forceDarkModeState: colorScheme === 'dark' ? 'dark' : 'light',
hideClientButton: true,
hideDarkModeToggle: true,
hideModels: schema === 'metadata',
pathRouting: {
basePath: getSettingsPath(SettingsPath.RestPlayground, {
schema,
}),
},
}}
/>
</Suspense>
authentication: {
http: {
bearer: { token: playgroundApiKey },
},
},
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
forceDarkModeState: colorScheme === 'dark' ? 'dark' : 'light',
hideClientButton: true,
hideDarkModeToggle: true,
hideModels: schema === 'metadata',
pathRouting: {
basePath: getSettingsPath(SettingsPath.RestPlayground, {
schema,
}),
},
}}
/>
</Suspense>
)}
</StyledContainer>
);
};
@@ -0,0 +1,40 @@
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { useEffect } from 'react';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
type RestPlaygroundSchemaFetchEffectProps = {
schema: PlaygroundSchemas;
apiKey: string;
onSchemaLoaded: (document: object | null) => void;
onError: () => void;
};
// Fetch via header so the token is never in a URL (logs, history, Referer).
export const RestPlaygroundSchemaFetchEffect = ({
schema,
apiKey,
onSchemaLoaded,
onError,
}: RestPlaygroundSchemaFetchEffectProps) => {
useEffect(() => {
onSchemaLoaded(null);
const abortController = new AbortController();
fetch(`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${schema}`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: abortController.signal,
})
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then(onSchemaLoaded)
.catch((error) => {
if (error?.name !== 'AbortError') {
onError();
}
});
return () => abortController.abort();
}, [schema, apiKey, onSchemaLoaded, onError]);
return null;
};