gitroomhq/postiz-app · error · HttpException

{ error: 'invalid_grant', error_description: 'Invalid code_v

Error message

{ error: 'invalid_grant', error_description: 'Invalid code_verifier' }

What it means

Returned as HTTP 400 invalid_grant when the PKCE code_verifier is provided but base64url(SHA-256(verifier)) does not equal the stored codeChallenge. The verifier must be the exact random string used to derive the challenge during authorization.

Source

Thrown at libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts:304

    }

    if (!auth.codeExpiresAt || new Date() > auth.codeExpiresAt) {
      throw new HttpException(
        { error: 'invalid_grant', error_description: 'Code has expired' },
        HttpStatus.BAD_REQUEST
      );
    }

    if (auth.codeChallenge) {
      if (!codeVerifier) {
        throw new HttpException(
          { error: 'invalid_grant', error_description: 'code_verifier is required' },
          HttpStatus.BAD_REQUEST
        );
      }
      const hashed = createHash('sha256').update(codeVerifier).digest('base64url');
      if (hashed !== auth.codeChallenge) {
        throw new HttpException(
          { error: 'invalid_grant', error_description: 'Invalid code_verifier' },
          HttpStatus.BAD_REQUEST
        );
      }
    }

    if (auth.redirectUri && redirectUri !== auth.redirectUri) {
      throw new HttpException(
        { error: 'invalid_grant', error_description: 'redirect_uri does not match the authorization request' },
        HttpStatus.BAD_REQUEST
      );
    }

    const token = 'pos_' + makeId(40);
    const encryptedToken = AuthService.fixedEncryption(token);
    const {
      organizationId,
      organization: { paymentId },

View on GitHub (pinned to 0f1647f749)

Solutions

  1. Regenerate the flow ensuring the SAME verifier instance is stored and later sent — use a standard OAuth library's PKCE support
  2. Verify hash derivation: base64url encoding of sha256 digest, no hex, no padding, no '+' '/' characters
  3. Trim whitespace and confirm the verifier charset/length (43-128 unreserved chars per RFC 7636)

Example fix

// before
const challenge = verifier; // wrong: plain
// after
const challenge = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))).toString('base64url');
Defensive patterns

Strategy: validation

Validate before calling

const hashed = createHash('sha256').update(verifier).digest('base64url');
if (hashed !== storedChallenge) throw new Error('verifier does not match — restart flow');

Type guard

const verifierMatches = (verifier: string, challenge: string): boolean => createHash('sha256').update(verifier).digest('base64url') === challenge;

Try / catch

try { return await exchange(body); } catch (e) { if (/Invalid code_verifier/.test(e?.response?.data?.error_description)) { return restartFlowWithPkce(); } throw e; }

Prevention

When it happens

Trigger: Using a different verifier than the one that generated the challenge (regenerated verifier on retry); computing the hash wrong (hex vs base64url, or sending the raw verifier as challenge at authorize time); verifier truncated or whitespace-padded.

Common situations: SPA reloaded and generated a new verifier; verifier stored/round-tripped through JSON with escaping issues; custom PKCE implementation hashing incorrectly; using 'plain' style but server stores S256.

Related errors


AI-assisted analysis of gitroomhq/postiz-app@0f1647f749 (2026-08-27). Data as JSON: /api/errors/d5fc4fb12f16f52b. Report an issue: GitHub.