Postiz has cross-tenant SUPERADMIN takeover via Skool-provider JWT forgery
Description
Postiz v2.21.8 fixes a JWT forgery in the Skool integration that allowed any authenticated user to escalate to SUPERADMIN and impersonate organizations.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Postiz v2.21.8 fixes a JWT forgery in the Skool integration that allowed any authenticated user to escalate to SUPERADMIN and impersonate organizations.
Vulnerability
In Postiz versions prior to 2.21.8, the Skool integration callback signed an attacker-controlled JSON blob into a session-shaped JWT using the application's JWT_SECRET. The authentication middleware then trusted every claim in that JWT without re-resolving the user from the database [3][4]. This allowed any authenticated Postiz user to forge a SUPERADMIN session and impersonate arbitrary organizations.
Exploitation
An attacker must be an authenticated Postiz user. By crafting a malicious JSON payload and triggering the Skool integration callback, the attacker obtains a JWT signed with the application's secret. This JWT contains forged claims (e.g., role: SUPERADMIN, organizationId: ). The middleware accepts these claims without verifying the user's actual privileges, granting the attacker elevated access [3].
Impact
Successful exploitation grants full access to all parts of Postiz, including all users registered on the instance. The attacker can also post on behalf of the victim's social media channels that are connected to that Postiz instance [3][4].
Mitigation
The vulnerability is fixed in Postiz version 2.21.8 [2], which includes commit 23696d2 [1]. No workaround is available; upgrading to the patched version is the only known mitigation [3]. The fix was released on 2026-06-16 [2].
AI Insight generated on Jun 17, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <2.21.8
Patches
123696d297351feat: security fixes
10 files changed · +41 −149
apps/backend/src/api/api.module.ts+0 −2 modified@@ -24,7 +24,6 @@ import { PublicController } from '@gitroom/backend/api/routes/public.controller' import { RootController } from '@gitroom/backend/api/routes/root.controller'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; -import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments'; import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller'; import { SignatureController } from '@gitroom/backend/api/routes/signature.controller'; import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller'; @@ -91,7 +90,6 @@ const authenticatedController = [ IntegrationManager, TrackService, ShortLinkService, - Nowpayments, AuthProviderManager, GithubProvider, GoogleProvider,
apps/backend/src/api/routes/billing.controller.ts+1 −7 modified@@ -8,7 +8,6 @@ import { ApiTags } from '@nestjs/swagger'; import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { Request } from 'express'; -import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; @ApiTags('Billing') @@ -17,8 +16,7 @@ export class BillingController { constructor( private _subscriptionService: SubscriptionService, private _stripeService: StripeService, - private _notificationService: NotificationService, - private _nowpayments: Nowpayments + private _notificationService: NotificationService ) {} @Get('/check/:id') @@ -198,8 +196,4 @@ export class BillingController { ); } - @Get('/crypto') - async crypto(@GetOrgFromRequest() org: Organization) { - return this._nowpayments.createPaymentPage(org.id); - } }
apps/backend/src/api/routes/no.auth.integrations.controller.ts+13 −3 modified@@ -233,8 +233,8 @@ export class NoAuthIntegrationsController { Buffer.from(body.code, 'base64').toString() ) : integrationProvider.isChromeExtension - ? AuthService.signJWT( - JSON.parse(Buffer.from(body.code, 'base64').toString()) + ? AuthService.fixedEncryption( + Buffer.from(body.code, 'base64').toString() ) : undefined ); @@ -297,8 +297,18 @@ export class NoAuthIntegrationsController { }) : undefined; + // Never leak stored credentials (signed/encrypted secrets) back to the + // caller. These columns hold the integration access token, refresh token + // and encrypted custom instance details and must stay server-side. + const { + token: _token, + refreshToken: _refreshToken, + customInstanceDetails: _customInstanceDetails, + ...safeIntegration + } = createUpdate as any; + return { - ...createUpdate, + ...safeIntegration, onboarding: onboarding === 'true', pages, ...(returnURL ? { returnURL } : {}),
apps/backend/src/api/routes/public.controller.ts+0 −7 modified@@ -19,7 +19,6 @@ import { Request, Response } from 'express'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service'; -import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; @@ -38,7 +37,6 @@ export class PublicController { private _trackService: TrackService, private _agentGraphInsertService: AgentGraphInsertService, private _postsService: PostsService, - private _nowpayments: Nowpayments, private _subscriptionService: SubscriptionService ) {} @Post('/agent') @@ -156,11 +154,6 @@ export class PublicController { } } - @Post('/crypto/:path') - async cryptoPost(@Body() body: any, @Param('path') path: string) { - console.log('cryptoPost', body, path); - return this._nowpayments.processPayment(path, body); - } @Get('/stream') async streamFile(
apps/backend/src/services/auth/auth.middleware.ts+10 −1 modified@@ -36,9 +36,18 @@ export class AuthMiddleware implements NestMiddleware { throw new HttpForbiddenException(); } try { - let user = AuthService.verifyJWT(auth) as User | null; + // Verify the JWT signature only. Never trust authorization-relevant + // claims (id, isSuperAdmin, activated) from the token body — always + // re-resolve the user from the database using the id. + const payload = AuthService.verifyJWT(auth) as User | null; const orgHeader = req.cookies.showorg || req.headers.showorg; + if (!payload?.id) { + throw new HttpForbiddenException(); + } + + let user = (await this._userService.getUserById(payload.id)) as User | null; + if (!user) { throw new HttpForbiddenException(); }
apps/frontend/src/components/billing/main.billing.component.tsx+0 −2 modified@@ -23,7 +23,6 @@ import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events'; import { useUtmUrl } from '@gitroom/helpers/utils/utm.saver'; import { useTrack } from '@gitroom/react/helpers/use.track'; import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; -import { PurchaseCrypto } from '@gitroom/frontend/components/billing/purchase.crypto'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { FinishTrial } from '@gitroom/frontend/components/billing/finish.trial'; import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; @@ -538,7 +537,6 @@ export const MainBillingComponent: FC<{ </div> ))} </div> - {!subscription?.id && <PurchaseCrypto />} {!!subscription?.id && ( <div className="flex justify-center mt-[20px] gap-[10px]"> <Button onClick={updatePayment}>
apps/frontend/src/components/billing/purchase.crypto.tsx+0 −30 removed@@ -1,30 +0,0 @@ -import { FC, useCallback, useState } from 'react'; -import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; -import { Button } from '@gitroom/react/form/button'; -import { useT } from '@gitroom/react/translation/get.transation.service.client'; -export const PurchaseCrypto: FC = () => { - const fetch = useFetch(); - const t = useT(); - - const [loading, setLoading] = useState(false); - const load = useCallback(async () => { - setLoading(true); - const data = await (await fetch('/billing/crypto')).json(); - window.location.href = data.invoice_url; - }, []); - return ( - <div className="flex-1 bg-sixth items-center border border-customColor6 rounded-[4px] p-[24px] gap-[16px] flex [@media(max-width:1024px)]:items-center"> - <div> - {t( - 'purchase_a_life_time_pro_account_with_sol_199', - 'Purchase a Life-time PRO account with SOL ($199)' - )} - </div> - <div> - <Button loading={loading} onClick={load}> - {t('purchase_now', 'Purchase now')} - </Button> - </div> - </div> - ); -};
libraries/nestjs-libraries/src/crypto/nowpayments.ts+0 −78 removed@@ -1,78 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; -import { AuthService } from '@gitroom/helpers/auth/auth.service'; -import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; - -export interface ProcessPayment { - payment_id: number; - payment_status: string; - pay_address: string; - price_amount: number; - price_currency: string; - pay_amount: number; - actually_paid: number; - pay_currency: string; - order_id: string; - order_description: string; - purchase_id: string; - created_at: string; - updated_at: string; - outcome_amount: number; - outcome_currency: string; -} - -@Injectable() -export class Nowpayments { - constructor(private _subscriptionService: SubscriptionService) {} - - async processPayment(path: string, body: ProcessPayment) { - const decrypt = AuthService.verifyJWT(path) as any; - if (!decrypt || !decrypt.order_id) { - return; - } - - if ( - body.payment_status !== 'confirmed' && - body.payment_status !== 'finished' - ) { - return; - } - - const [org, make] = body.order_id.split('_'); - await this._subscriptionService.lifeTime(org, make, 'PRO'); - return body; - } - - async createPaymentPage(orgId: string) { - const onlyId = makeId(5); - const make = orgId + '_' + onlyId; - const signRequest = AuthService.signJWT({ order_id: make }); - - const { id, invoice_url } = await ( - await fetch('https://api.nowpayments.io/v1/invoice', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.NOWPAYMENTS_API_KEY!, - }, - body: JSON.stringify({ - price_amount: process.env.NOWPAYMENTS_AMOUNT, - price_currency: 'USD', - order_id: make, - pay_currency: 'SOL', - order_description: 'Lifetime deal account for Postiz', - ipn_callback_url: - process.env.NEXT_PUBLIC_BACKEND_URL + - `/public/crypto/${signRequest}`, - success_url: process.env.FRONTEND_URL + `/launches?check=${onlyId}`, - cancel_url: process.env.FRONTEND_URL, - }), - }) - ).json(); - - return { - id, - invoice_url, - }; - } -}
libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts+0 −14 modified@@ -247,20 +247,6 @@ export class SubscriptionService { }; } - async lifeTime(orgId: string, identifier: string, subscription: any) { - return this.createOrUpdateSubscription( - false, - identifier, - identifier, - pricing[subscription].channel!, - subscription, - 'YEARLY', - null, - identifier, - orgId - ); - } - async addSubscription(orgId: string, userId: string, subscription: any) { await this._subscriptionRepository.setCustomerId(orgId, userId); return this.createOrUpdateSubscription(
libraries/nestjs-libraries/src/integrations/social/skool.provider.ts+17 −5 modified@@ -31,10 +31,22 @@ export class SkoolProvider extends SocialAbstract implements SocialProvider { client_id: string; auth_token: string; } { - return AuthService.verifyJWT(integration.customInstanceDetails!) as { - client_id: string; - auth_token: string; - }; + const stored = integration.customInstanceDetails!; + try { + // Current format: credentials stored as at-rest AES-encrypted JSON. + return JSON.parse(AuthService.fixedDecryption(stored)) as { + client_id: string; + auth_token: string; + }; + } catch { + // Legacy format: Skool accounts connected before the storage format was + // changed kept their credentials as a signed JWT. Read those as-is so + // already-connected accounts keep working without a reconnect. + return AuthService.verifyJWT(stored) as { + client_id: string; + auth_token: string; + }; + } } override handleErrors( @@ -106,7 +118,7 @@ export class SkoolProvider extends SocialAbstract implements SocialProvider { return { refreshToken: '', expiresIn: dayjs().add(100, 'year').unix() - dayjs().unix(), - accessToken: AuthService.signJWT(cookies), + accessToken: AuthService.fixedEncryption(JSON.stringify(cookies)), id: data.id, name: data.first_name + ' ' + data.last_name, picture: data.metadata.picture_profile || '',
Vulnerability mechanics
Root cause
"The auth middleware trusts all JWT claims without re-resolving the user from the database, and the Skool integration signs attacker-controlled JSON into a JWT using the application's JWT_SECRET."
Attack vector
An authenticated Postiz user triggers the Skool OAuth callback, which signs the attacker-supplied JSON blob (containing arbitrary `id`, `isSuperAdmin`, and `activated` claims) into a JWT using the application's `JWT_SECRET` [ref_id=1]. The `AuthMiddleware` then verifies only the JWT signature and blindly trusts the claims in the token body, never re-resolving the user from the database [patch_id=6240378]. This allows the attacker to forge a `SUPERADMIN` session and impersonate arbitrary organizations, gaining full access to all parts of Postiz including the ability to post on behalf of victim social media channels.
Affected code
The vulnerability centers on the `AuthMiddleware` in `apps/backend/src/services/auth/auth.middleware.ts` and the Skool integration in `libraries/nestjs-libraries/src/integrations/social/skool.provider.ts`. The middleware previously trusted all JWT claims (including `id`, `isSuperAdmin`, `activated`) from the token body without re-resolving the user from the database. The Skool callback signed an attacker-controlled JSON blob into a JWT using the application's `JWT_SECRET`, allowing any authenticated user to forge arbitrary claims.
What the fix does
The patch modifies `AuthMiddleware` to first verify the JWT signature and extract only the `id` claim, then re-resolve the full user record from the database via `getUserById` [patch_id=6240378]. This ensures that authorization-relevant fields (`isSuperAdmin`, `activated`, organization membership) come from the database, not from attacker-controlled token claims. Additionally, the Skool integration now stores credentials using `fixedEncryption` (AES) instead of `signJWT`, preventing the signing of attacker-controlled data into a JWT. The `Nowpayments` module and its associated routes are removed entirely, eliminating another code path that signed attacker-controlled data.
Preconditions
- authAttacker must be an authenticated user of the Postiz instance.
- inputThe Skool integration callback must be reachable and accept attacker-controlled JSON.
- configThe application's JWT_SECRET must be the same one used by the auth middleware.
Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/gitroomhq/postiz-app/releases/tag/v2.21.8mitrex_refsource_MISC
- gadvisory.org/advisories/PSA-2026-2CAQ96mitrex_refsource_MISC
- github.com/gitroomhq/postiz-app/commit/23696d2973510ae1f3f48bfa41a6bfbbf9827b05mitrex_refsource_MISC
- github.com/gitroomhq/postiz-app/security/advisories/GHSA-j77w-h625-56q2mitrex_refsource_CONFIRM
News mentions
0No linked articles in our index yet.