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
@@ -7,7 +7,6 @@ import {
import { createHash } from 'crypto';
import { type Request as ExpressRequest } from 'express';
import * as jwt from 'jsonwebtoken';
import { ExtractJwt, type JwtFromRequestFunction } from 'passport-jwt';
import { isDefined } from 'twenty-shared/utils';
@@ -131,18 +130,6 @@ export class JwtWrapperService {
}
extractJwtFromRequest(): JwtFromRequestFunction {
return (request: ExpressRequest) => {
// First try to extract token from Authorization header
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (tokenFromHeader) {
return tokenFromHeader;
}
// If not found in header, try to extract from URL query parameter
// This is for edge cases where we don't control the origin request
// (e.g. the REST API playground)
return ExtractJwt.fromUrlQueryParameter('token')(request);
};
return ExtractJwt.fromAuthHeaderAsBearerToken();
}
}
@@ -100,12 +100,7 @@ export class OpenApiService {
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
const tokenFromQuery = request.query.token;
const schema = baseSchema(
'core',
baseUrl,
typeof tokenFromQuery === 'string' ? tokenFromQuery : undefined,
);
const schema = baseSchema('core', baseUrl);
const workspace = await this.getWorkspaceFromRequest(request);
@@ -263,12 +258,7 @@ export class OpenApiService {
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
const tokenFromQuery = request.query.token;
const schema = baseSchema(
'metadata',
baseUrl,
typeof tokenFromQuery === 'string' ? tokenFromQuery : undefined,
);
const schema = baseSchema('metadata', baseUrl);
const workspace = await this.getWorkspaceFromRequest(request);
@@ -7,7 +7,6 @@ export const API_Version = 'v0.1';
export const baseSchema = (
schemaName: 'core' | 'metadata',
serverUrl: string,
token?: string,
): OpenAPIV3_1.Document => {
return {
openapi: '3.1.1',
@@ -31,6 +30,10 @@ curl -H 'Authorization: Bearer <token>' <server>/rest/core/companies
Tokens can be generated in Settings → Playground and are workspace-scoped.
> **Never put your token in a URL.** Tokens passed as query parameters end up in
> server access logs, browser history, and \`Referer\` headers, which is why the
> API only accepts tokens from the \`Authorization\` header.
## Filters
@@ -101,24 +104,25 @@ order_by=id[AscNullsFirst],createdAt[DescNullsLast]
## Usage with LLMs
You can use AI to generate code based on the OpenAPI schema with the following URLs:
The recommended way to give an LLM agent (Claude Desktop, Cursor, Windsurf, …)
access to your workspace is the **Twenty MCP server**, not this OpenAPI schema.
The MCP server exposes typed tools the agent can call directly with proper
header-based auth (OAuth or API key), no tokens in URLs.
Configure it from **Settings → AI → MCP** in your workspace. The endpoint is:
\`\`\`text
Core: ${serverUrl}/rest/open-api/core?token=${token ?? '<your_token>'}
Metadata: ${serverUrl}/rest/open-api/metadata?token=${token ?? '<your_token>'}
${serverUrl}/mcp
\`\`\`
Quick prompt example (Cursor or any agent):
If you specifically need the raw OpenAPI document (for code generation, type
generation, etc.), download it locally with a header-authenticated request and
hand the file to your tool — never paste a tokenized URL into a chat:
\`\`\`text
Here is an OpenAPI schema for the Twenty REST API:\n${serverUrl}/rest/open-api/core?token=${token ?? '<your_token>'}
Use it to list companies created after 2024-01-01, ordered by createdAt desc, and include only 20 results.
\`\`\`bash
curl -H 'Authorization: Bearer <token>' \\
${serverUrl}/rest/open-api/${schemaName} > twenty-${schemaName}.json
\`\`\`
Notes:
- Treat the token like a secret; prefer a short-lived Playground token.
- Most editors can fetch and process the schema even if it's large.
`,
termsOfService:
'https://github.com/twentyhq/twenty?tab=coc-ov-file#readme',