ory/hydra · error

malformed ciphertext: too short

Error message

malformed ciphertext: too short

What it means

Decrypt base64-url-decodes the ciphertext and requires at least chacha20poly1305.NonceSizeX bytes so a nonce can be split off. A decoded message shorter than the nonce size cannot possibly be valid XChaCha20-Poly1305 output, so Decrypt returns 'malformed ciphertext: too short'.

Source

Thrown at aead/xchacha20.go:63

	nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(plaintext)+aead.Overhead())
	_, err = cryptorand.Read(nonce)
	if err != nil {
		return "", errors.WithStack(err)
	}

	ciphertext := aead.Seal(nonce, nonce, plaintext, additionalData)
	return base64.URLEncoding.EncodeToString(ciphertext), nil
}

func (x *XChaCha20Poly1305) Decrypt(ctx context.Context, ciphertext string, aad []byte) (plaintext []byte, err error) {
	msg, err := base64.URLEncoding.DecodeString(ciphertext)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	if len(msg) < chacha20poly1305.NonceSizeX {
		return nil, errors.WithStack(fmt.Errorf("malformed ciphertext: too short"))
	}
	nonce, ciphered := msg[:chacha20poly1305.NonceSizeX], msg[chacha20poly1305.NonceSizeX:]

	keys, err := allKeys(ctx, x.d)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	var aead cipher.AEAD
	for _, key := range keys {
		aead, err = chacha20poly1305.NewX(key)
		if err != nil {
			continue
		}
		plaintext, err = aead.Open(nil, nonce, ciphered, aad)
		if err == nil {
			return plaintext, nil
		}

View on GitHub (pinned to 4174065ffb)

Solutions

  1. Ensure the value passed to Decrypt is the exact string returned by Encrypt (base64url, untouched).
  2. Check the storage column is large enough and the value was not truncated (log length before decrypt).
  3. Re-encrypt the data: decrypt with the original mechanism/key, then Encrypt again with the current key set.
  4. Guard call sites: only attempt Decrypt on values produced by Encrypt, return a fallback for legacy formats.

Example fix

// before
token := strings.Split(raw, ":")[1] // custom mangling
val, err := x.Decrypt(ctx, token)

// after
val, err := x.Decrypt(ctx, raw) // pass stored ciphertext as-is
if err != nil { return nil, fmt.Errorf("unreadable token: %w", err) }
Defensive patterns

Strategy: validation

Validate before calling

// Cheap pre-check before calling Decrypt:
if raw == "" || base64.RawURLEncoding.DecodedLen(len(raw)) < 24 { // XChaCha nonce size
    return fmt.Errorf("not a valid encrypted token")
}

Type guard

func looksLikeCiphertext(s string) bool {
    if s == "" { return false }
    msg, err := base64.URLEncoding.DecodeString(s)
    return err == nil && len(msg) >= chacha20poly1305.NonceSizeX
}

Try / catch

val, err := x.Decrypt(ctx, ct)
if err != nil {
    if strings.Contains(err.Error(), "malformed ciphertext") {
        return nil, ErrCorruptToken // treat as absent/invalid, do not retry
    }
    return nil, err
}

Prevention

When it happens

Trigger: Calling Decrypt with a string that is not output of Encrypt: truncated database value, empty string, hand-crafted or corrupted token, wrong encoding (raw/hex instead of base64url) yielding a too-short decode.

Common situations: Data written by an older/other algorithm in the same column; manual DB edits or migrations truncating TEXT columns; passing a plain secret string instead of an encrypted token; different keys causing misinterpretation (though that usually fails auth, not length).

Understand the failure class

Related errors


AI-assisted analysis of ory/hydra@4174065ffb (2026-09-03). Data as JSON: /api/errors/d546f6a2ada822a3. Report an issue: GitHub.