Skip to content

fix: Metabase embed on /careers showing "Message seems corrupt or manipulated"#2829

Open
felixkrrr wants to merge 1 commit intomainfrom
cursor/fix-metabase-embed-68c8
Open

fix: Metabase embed on /careers showing "Message seems corrupt or manipulated"#2829
felixkrrr wants to merge 1 commit intomainfrom
cursor/fix-metabase-embed-68c8

Conversation

@felixkrrr
Copy link
Copy Markdown
Contributor

@felixkrrr felixkrrr commented Apr 21, 2026

Problem

The Metabase embed on /careers (Public Metrics Dashboard) shows "Message seems corrupt or manipulated" after the METABASE_SECRET_KEY was rotated in Vercel.

This error occurs when Metabase cannot validate the JWT signature — i.e. the key used to sign the token doesn't match the key Metabase expects.

Root Cause

Two issues in app/api/metabase-embed/route.ts:

  1. Whitespace in env variable — When pasting the new secret key into Vercel's env variable UI, trailing whitespace or newlines are common. The key was used as-is from process.env without trimming, causing the JWT signature to be invalid.

  2. Aggressive CDN caching — The API response had Cache-Control: public, s-maxage=300, meaning Vercel's CDN could serve stale JWTs (signed with the old key) for up to 5 minutes after a key rotation. This could also cause intermittent failures.

Fix

  • Trim the secret key before using it to sign the JWT (secretKeyRaw.trim()).
  • Disable response caching (no-store, no-cache, must-revalidate) so each request generates a fresh JWT. The client-side component already handles token refresh every 8 minutes, so CDN caching of these short-lived JWTs provides little benefit.

Verification

After deploying, confirm that METABASE_SECRET_KEY is correctly set in Vercel env variables. The embed should load the Metabase dashboard without errors.

Resolves LFE-9403

Linear Issue: LFE-9403

Open in Web Open in Cursor 

Disclaimer: Experimental PR review

Greptile Summary

This PR fixes a broken Metabase embed by trimming whitespace from the METABASE_SECRET_KEY env variable before signing the JWT, and by replacing the aggressive s-maxage=300 CDN cache header with no-store, no-cache, must-revalidate so stale tokens can't be served after a key rotation. It also hoists METABASE_SITE_URL to module scope — a minor cleanup aligned with the team's import/constant style.

Confidence Score: 5/5

Safe to merge — both root causes are correctly addressed and the only remaining finding is a minor hardening suggestion.

All remaining findings are P2. The trim fix directly resolves the reported bug, and disabling CDN caching eliminates the key-rotation race condition. The one open comment (guard against whitespace-only value after trim) is a hardening suggestion and does not block the fix from working in practice.

No files require special attention.

Important Files Changed

Filename Overview
app/api/metabase-embed/route.ts Fixes JWT signing failure by trimming whitespace from the secret key and disabling CDN caching of short-lived tokens; also hoists the site URL constant to module scope.

Sequence Diagram

sequenceDiagram
    participant Client as Browser / Next.js Component
    participant API as /api/metabase-embed
    participant Env as Vercel Env
    participant MB as Metabase

    Client->>API: GET /api/metabase-embed?dashboardId=25&theme=night
    API->>Env: read METABASE_SECRET_KEY
    Env-->>API: raw value (may contain whitespace)
    Note over API: trim() the raw value
    Note over API: jwt.sign(payload, trimmedValue, HS256)
    API-->>Client: { iframeUrl, expiresAt } Cache-Control: no-store
    Client->>MB: iframe loads signed URL
    MB-->>Client: Dashboard rendered
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: app/api/metabase-embed/route.ts
Line: 18

Comment:
**Empty string not guarded after trim**

If `METABASE_SECRET_KEY` is set to a whitespace-only string (e.g. `"  \n"`), the `!secretKeyRaw` guard passes because a non-empty string is truthy. After `.trim()`, the value becomes `""`. `jsonwebtoken` accepts an empty string and produces a token, but Metabase will reject it with the same "corrupt or manipulated" error. A post-trim emptiness check would close this gap and surface a clearer error message in the logs.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: trim METABASE_SECRET_KEY and disabl..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

The 'Message seems corrupt or manipulated' error from Metabase occurs when
the JWT signature doesn't match the expected secret key. After a key
rotation, this is commonly caused by:

1. Whitespace/newlines in the env variable value (common when copy-pasting
   into Vercel). Fixed by trimming the secret key before signing.

2. Stale JWTs served from CDN cache (old s-maxage=300). Fixed by switching
   to no-store caching so each request generates a fresh JWT with the
   current key.

Co-authored-by: felixkrrr <felixkrrr@users.noreply.github.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
langfuse-docs Ready Ready Preview, Comment Apr 21, 2026 11:32am

Request Review

@dosubot dosubot Bot added the size:S This PR changes 10-29 lines, ignoring generated files. label Apr 21, 2026
@github-actions
Copy link
Copy Markdown

@claude review

@dosubot dosubot Bot added the bug Something isn't working label Apr 21, 2026
);
}

const secretKey = secretKeyRaw.trim();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty string not guarded after trim

If METABASE_SECRET_KEY is set to a whitespace-only string (e.g. " \n"), the !secretKeyRaw guard passes because a non-empty string is truthy. After .trim(), the value becomes "". jsonwebtoken accepts an empty string and produces a token, but Metabase will reject it with the same "corrupt or manipulated" error. A post-trim emptiness check would close this gap and surface a clearer error message in the logs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/api/metabase-embed/route.ts
Line: 18

Comment:
**Empty string not guarded after trim**

If `METABASE_SECRET_KEY` is set to a whitespace-only string (e.g. `"  \n"`), the `!secretKeyRaw` guard passes because a non-empty string is truthy. After `.trim()`, the value becomes `""`. `jsonwebtoken` accepts an empty string and produces a token, but Metabase will reject it with the same "corrupt or manipulated" error. A post-trim emptiness check would close this gap and surface a clearer error message in the logs.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the key-trimming fix and cache-control change correctly address the reported JWT signing failure.

Extended reasoning...

Overview

Single-file change to app/api/metabase-embed/route.ts: trims METABASE_SECRET_KEY before signing the JWT, disables CDN caching of token responses, and moves the METABASE_SITE_URL constant outside the request handler. All changes are tightly scoped to the reported bug.

Security risks

Minimal. This is a public-facing embed token endpoint, not an auth or permission boundary. The only credentials in play are a Metabase embed secret key. The trimming fix reduces the attack surface (whitespace-polluted keys producing invalid tokens). Disabling s-maxage prevents stale signed tokens from being served by the CDN, which is the correct posture for short-lived JWTs.

Level of scrutiny

Low. The diff is a handful of lines, the intent is obvious, the fix directly maps to the described root cause, and the endpoint is low-criticality (public Metabase dashboard embed on the /careers page). The two inline bug comments are a pre-existing NaN edge case (only reachable via crafted external requests, zero impact on the normal flow) and a minor logical-ordering nit for the all-whitespace key degenerate case. Neither rises to blocking level.

Other factors

No tests exist for this route, but the logic is simple enough that the diff is self-evidently correct. The inline nit about checking the trimmed value is worth a follow-up, but does not affect the shipped fix for the reported production issue.

Comment on lines +10 to 21
if (!secretKeyRaw) {
console.error("Missing required environment variable: METABASE_SECRET_KEY");
return NextResponse.json(
{ message: "Server configuration error" },
{ status: 500 }
);
}

const secretKey = secretKeyRaw.trim();

const dashboardId = request.nextUrl.searchParams.get("dashboardId")
? parseInt(request.nextUrl.searchParams.get("dashboardId")!, 10)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The emptiness check runs on secretKeyRaw (before trimming), so a whitespace-only METABASE_SECRET_KEY passes the guard and then trims to "", causing jwt.sign() to throw and return a generic 500 "Internal Server Error" instead of the intended "Server configuration error". Move the check to after the trim: replace if (\!secretKeyRaw) with const secretKey = secretKeyRaw.trim(); first, then if (\!secretKey).

Extended reasoning...

Bug: The guard if (\!secretKeyRaw) in app/api/metabase-embed/route.ts (line 10) runs on the raw, untrimmed environment variable value. A non-empty but whitespace-only value such as ' ' or '\n' is truthy in JavaScript, so the early-return guard is skipped entirely.

Code path: After the guard passes, const secretKey = secretKeyRaw.trim() runs (line 21), producing secretKey = "". The empty string is then passed directly to jwt.sign(payload, secretKey). In jsonwebtoken v9.x, the library internally checks if (\!secretOrPrivateKey) and throws Error: secretOrPrivateKey must have a value.

Why existing code doesn't prevent it: The outer try/catch block does catch the thrown error, but it falls into the generic handler which returns { message: "Internal Server Error" } with status 500 — not the intentional { message: "Server configuration error" } path with its console.error("Missing required environment variable: METABASE_SECRET_KEY") log line that would aid diagnosis.

Impact: If someone accidentally pastes only whitespace into the Vercel env var UI for METABASE_SECRET_KEY, the embed endpoint returns a confusing generic 500 with no targeted log message, making it harder to diagnose the root cause. The PR's primary fix (trimming a real key that has surrounding whitespace) works correctly; this is a logical ordering nit for the degenerate all-whitespace case.

Fix: Swap the order — call .trim() first, then check the trimmed result:

const secretKey = (process.env.METABASE_SECRET_KEY ?? '').trim();
if (\!secretKey) {
  console.error('Missing required environment variable: METABASE_SECRET_KEY');
  return NextResponse.json({ message: 'Server configuration error' }, { status: 500 });
}

Step-by-step proof:

  1. Operator sets METABASE_SECRET_KEY=" " (a single space) in Vercel.
  2. secretKeyRaw = " " — a truthy string of length 1.
  3. \!secretKeyRaw evaluates to false; the guard does not return early.
  4. secretKey = secretKeyRaw.trim()secretKey = "".
  5. jwt.sign(payload, "") → jsonwebtoken throws Error: secretOrPrivateKey must have a value.
  6. Caught by outer catch; response is 500 Internal Server Error with no targeted log.
  7. Expected behaviour (with fix): \!secretKey is true → clean 500 Server configuration error with the descriptive console.error log.

Comment on lines 20 to +23
const dashboardId = request.nextUrl.searchParams.get("dashboardId")
? parseInt(request.nextUrl.searchParams.get("dashboardId")!, 10)
: 25;
const theme = request.nextUrl.searchParams.get("theme") === "day" ? "day" : "night";
const theme =
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing issue: passing a non-numeric dashboardId query parameter (e.g., ?dashboardId=abc) causes parseInt to return NaN, which JSON.stringify serializes as null, resulting in a JWT payload of { resource: { dashboard: null } } that Metabase will reject. In practice this is only reachable via a crafted direct API request since the only internal caller always passes a static integer; adding an isNaN guard with a fallback to 25 would harden the endpoint.

Extended reasoning...

What the bug is

At app/api/metabase-embed/route.ts lines 20-23, the dashboardId query parameter is parsed with parseInt(..., 10). The ternary guard only checks whether the parameter is present/truthy — it does not validate that the value is actually numeric. If a caller passes a non-numeric string, parseInt returns NaN.

The problematic serialization path

NaN is not a valid JSON value. When JSON.stringify serializes an object containing NaN, it silently converts it to null:

JSON.stringify({ x: NaN }) // '{x:null}'

jsonwebtoken serializes the payload as JSON internally, so a request with ?dashboardId=abc produces the JWT payload { resource: { dashboard: null }, params: {}, exp: ... }. Metabase's embed endpoint expects dashboard to be a valid integer and rejects the token.

Why existing code does not prevent it

The ternary request.nextUrl.searchParams.get('dashboardId') ? parseInt(...) : 25 only guards against a missing or empty parameter. A non-numeric value like 'abc' is truthy, so the branch is taken and parseInt('abc', 10) returns NaN without any further check.

Impact

For the normal application flow the impact is zero: the single internal caller (components/MetabaseDashboard.tsx) is TypeScript-typed with dashboardId: number and hardcodes the value 25. However, the API route is publicly reachable, so any crafted request with a non-numeric dashboardId will receive a 200 response containing a JWT that Metabase immediately rejects — yielding a confusing error rather than a clean 400.

Concrete step-by-step proof

  1. GET /api/metabase-embed?dashboardId=abc
  2. searchParams.get('dashboardId') returns 'abc' (truthy) → ternary takes the parseInt branch
  3. parseInt('abc', 10)NaN
  4. dashboardId = NaN; payload becomes { resource: { dashboard: NaN }, ... }
  5. jwt.sign(payload, secretKey) serializes payload as JSON → { resource: { dashboard: null }, ... }
  6. Metabase receives a token with dashboard: null and returns an error

Relationship to this PR

This logic was not changed by the PR (which only trims the secret key and updates cache headers). It is a pre-existing issue, but the PR touches the same file and function, making it a natural opportunity to add the guard.

Fix

const raw = request.nextUrl.searchParams.get('dashboardId');
const parsed = raw ? parseInt(raw, 10) : NaN;
const dashboardId = \!isNaN(parsed) ? parsed : 25;

Or return a 400 if an invalid value is explicitly supplied.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants