HeyPuter/puter · error · HttpError

invalid_token

invalid_token

Error message

wrong token type

What it means

Thrown by POST /wisp/relay-token/verify when the token's signature verifies under the 'wisp' purpose but its `$` discriminator field is not the literal `token:wisp`. Every wisp relay token carries this type tag so a token minted for another purpose can't be replayed here. IMPORTANT: this throw lives inside a try/catch whose catch unconditionally rethrows a generic 'Forbidden' (line 126), so a client never actually receives this message — it is always masked into error 301's 'Forbidden'. Treat it as an internal/defensive guard, not a user-facing surface.

Source

Thrown at src/backend/controllers/wisp/WispController.ts:122

    };

    /** POST /wisp/relay-token/verify — verify a relay token and apply policy. */
    #verify = async (req: Request, res: Response): Promise<void> => {
        const bodyToken = req.body?.token;
        if (!bodyToken || typeof bodyToken !== 'string') {
            throw new HttpError(400, 'Missing `token`', {
                legacyCode: 'token_missing',
            });
        }

        let decoded: Record<string, unknown>;
        try {
            decoded = this.services.token.verify<Record<string, unknown>>(
                'wisp',
                bodyToken,
            );
            if (decoded.$ !== 'token:wisp')
                throw new HttpError(403, 'wrong token type', {
                    legacyCode: 'invalid_token',
                });
        } catch {
            throw new HttpError(403, 'Forbidden', {
                legacyCode: 'invalid_token',
            });
        }

        // Build policy event — extensions can deny via extension.on('wisp.get-policy')
        const isGuest = Boolean(decoded.guest);
        let user: Record<string, unknown> | null = null;
        if (!isGuest && decoded.user_uid) {
            user = await this.stores.user.getByUuid(String(decoded.user_uid));
        }

        const event: Record<string, unknown> = {
            allow: true,
            policy: { allow: true },

View on GitHub (pinned to 908ec23eda)

Solutions

  1. Fetch a fresh relay token from POST /wisp/relay-token — the mint endpoint always sets `$: 'token:wisp'`.
  2. If you mint wisp tokens yourself, ensure the payload includes `$: 'token:wisp'`.
  3. Do not reuse tokens obtained from other endpoints on the verify route.

Example fix

// before (client reuses a foreign token)
verify({ token: someOtherToken });
// after (fetch a fresh wisp relay token first)
const { token } = await fetch('/wisp/relay-token', { method: 'POST' }).then(r => r.json());
verify({ token });
Defensive patterns

Strategy: validation

Validate before calling

// If you mint wisp tokens yourself, validate the discriminator before sending:
function isWispRelayTokenPayload(d) {
  return d != null && typeof d === 'object' && d['$'] === 'token:wisp';
}
if (!isWispRelayTokenPayload(decodedPayload)) {
  throw new Error('not a wisp relay token — refetch from /wisp/relay-token');
}

Type guard

const isWispTokenPayload = (d) => d != null && typeof d === 'object' && d['$'] === 'token:wisp';

Try / catch

// Note: this message is masked by the surrounding catch (see error 301).
// Clients always receive the generic 'Forbidden' — handle it there.

Prevention

When it happens

Trigger: POST /wisp/relay-token/verify with a token that is a valid wisp-secret-signed JWT but whose payload lacks `$` or sets `$` to something other than `token:wisp` (e.g. a hand-built token, or one minted by an older code path that did not set the tag).

Common situations: Token format version mismatch after an upgrade; a client reusing a token minted by a different endpoint that happens to share the wisp secret; test fixtures that build tokens manually without the `$` field.

Related errors


AI-assisted analysis of HeyPuter/puter@908ec23eda (2026-08-12). Data as JSON: /api/errors/b88969a900e4d6e7. Report an issue: GitHub.