fix: Metabase embed on /careers showing "Message seems corrupt or manipulated"#2829
fix: Metabase embed on /careers showing "Message seems corrupt or manipulated"#2829
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
@claude review |
| ); | ||
| } | ||
|
|
||
| const secretKey = secretKeyRaw.trim(); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
🟡 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:
- Operator sets
METABASE_SECRET_KEY=" "(a single space) in Vercel. secretKeyRaw = " "— a truthy string of length 1.\!secretKeyRawevaluates tofalse; the guard does not return early.secretKey = secretKeyRaw.trim()→secretKey = "".jwt.sign(payload, "")→ jsonwebtoken throwsError: secretOrPrivateKey must have a value.- Caught by outer
catch; response is500 Internal Server Errorwith no targeted log. - Expected behaviour (with fix):
\!secretKeyistrue→ clean500 Server configuration errorwith the descriptiveconsole.errorlog.
| 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 = |
There was a problem hiding this comment.
🟣 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
GET /api/metabase-embed?dashboardId=abcsearchParams.get('dashboardId')returns'abc'(truthy) → ternary takes theparseIntbranchparseInt('abc', 10)→NaNdashboardId = NaN; payload becomes{ resource: { dashboard: NaN }, ... }jwt.sign(payload, secretKey)serializes payload as JSON →{ resource: { dashboard: null }, ... }- Metabase receives a token with
dashboard: nulland 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.
Problem
The Metabase embed on
/careers(Public Metrics Dashboard) shows "Message seems corrupt or manipulated" after theMETABASE_SECRET_KEYwas 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: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.envwithout trimming, causing the JWT signature to be invalid.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
secretKeyRaw.trim()).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_KEYis correctly set in Vercel env variables. The embed should load the Metabase dashboard without errors.Resolves LFE-9403
Linear Issue: LFE-9403
Disclaimer: Experimental PR review
Greptile Summary
This PR fixes a broken Metabase embed by trimming whitespace from the
METABASE_SECRET_KEYenv variable before signing the JWT, and by replacing the aggressives-maxage=300CDN cache header withno-store, no-cache, must-revalidateso stale tokens can't be served after a key rotation. It also hoistsMETABASE_SITE_URLto 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
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 renderedPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix: trim METABASE_SECRET_KEY and disabl..." | Re-trigger Greptile