Encryption & Signing

Rhapsody ships with a small, fast encryption service built on PHP's bundled OpenSSL extension. It covers the four things most applications actually need: encrypting a value, signing a value so tampering is detectable, signed URLs, and encrypted database columns. There are no extra Composer dependencies, and the service is only instantiated if your code uses it.

Encryption is authenticated (AES-256-GCM): a payload that was modified, or that was produced with a different key, simply fails to decrypt. The framework's own cookie storage is built on this service.


Configuration

Everything is derived from the APP_KEY in your .env file. The key must be at least 32 bytes; 64 random hex characters is recommended:

APP_KEY=your-64-character-hex-key

Generate one with: php -r "echo bin2hex(random_bytes(32));"

Separate encryption and signing keys are derived from APP_KEY using HKDF, so you only ever manage one secret. If APP_KEY is missing or too short, the first use of the service throws an exception rather than falling back to an insecure default.


Encrypting & Decrypting

Type-hint EncrypterInterface in a controller or service and the container will inject it, or use the shared instance anywhere:

use Rhapsody\Core\Contracts\EncrypterInterface;
use Rhapsody\Core\Services\Encrypter;

// Via dependency injection
public function __construct(protected EncrypterInterface $encrypter) {}

// Or the shared instance
$encrypter = Encrypter::getInstance();

$token = $encrypter->encrypt('my secret value');
// "AQ3x...": safe to use in cookies, URLs and form fields

$plain = $encrypter->decrypt($token);
// "my secret value", or null if it was tampered with or can't be decrypted

decrypt() returns null on any failure, so always compare with === null. An empty string is a valid decrypted value.

Values are strings. To encrypt an array or object, json_encode() it first and json_decode() the result.

Contexts

Every method accepts an optional context string that is cryptographically bound to the result. A value encrypted under one context will not decrypt under another, which prevents a valid payload from being copied somewhere it was never meant to go.

$token = $encrypter->encrypt('42', 'invoice-download');

$encrypter->decrypt($token, 'invoice-download'); // "42"
$encrypter->decrypt($token, 'password-reset');   // null
$encrypter->decrypt($token);                     // null

Signing

Signing leaves the value readable but makes any change detectable. Use it when the user may see the data and you only need to know it hasn't been altered.

$signed = $encrypter->sign('user:42');
// "user:42.Zr8k..."

$value = $encrypter->unsign($signed);
// "user:42", or null if the value or signature was changed

// Detached signatures, if you want to store the signature separately
$sig = $encrypter->signature($data, 'my-context');
$ok  = $encrypter->verifySignature($data, $sig, 'my-context');

Signed URLs

UrlSigner adds a signature query parameter to a URL, and optionally an expires timestamp that is covered by the signature. It is ideal for email verification, unsubscribe links and temporary downloads.

use Rhapsody\Core\Services\UrlSigner;

public function __construct(protected UrlSigner $signer) {}

// Valid until changed
$url = $this->signer->sign('https://example.com/unsubscribe?user=42');

// Valid for one hour
$url = $this->signer->sign('https://example.com/download?file=7', 3600);

// In the route that receives the link: pass the full URL of the request
if (! $this->signer->verify($fullRequestUrl)) {
    // invalid signature, altered parameters, or expired
}

Sign and verify the same form of URL. A link signed as a relative path (/verify?id=5) will not verify when checked as an absolute URL.


Key Rotation

Changing APP_KEY would normally make everything encrypted with the old key unreadable. To rotate without breaking existing data, move the old key into APP_PREVIOUS_KEYS (comma-separated):

APP_KEY=the-new-64-character-hex-key
APP_PREVIOUS_KEYS=the-old-key,an-even-older-key

New data is always written with APP_KEY. When reading, the current key is tried first, then each previous key. Once you are confident nothing encrypted with an old key remains (cookies have expired, database columns have been re-saved), remove it from the list.


Encrypted Database Columns

The encrypted Doctrine type encrypts a value when it is saved and decrypts it when it is loaded, so the database only ever contains ciphertext. The type is registered by the framework; just use it on an entity property:

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Integration
{
    #[ORM\Column(type: 'encrypted', nullable: true)]
    private ?string $apiKey = null;

    public function getApiKey(): ?string { return $this->apiKey; }
    public function setApiKey(?string $key): void { $this->apiKey = $key; }
}

The column is created as TEXT on MySQL, SQL Server, PostgreSQL and SQLite. In a hand-written migration, use a text column.

Things to know:

  • You cannot query by an encrypted column. Each encryption uses a random nonce, so the same plaintext produces different ciphertext every time. WHERE, ORDER BY and indexes won't work on it. If you need lookups, store a separate hash column.
  • Strings only. json_encode() arrays before assigning them.
  • Unreadable data throws. If a stored value can't be decrypted (corrupt, or the key changed without being added to APP_PREVIOUS_KEYS), loading the entity throws an exception instead of returning an empty value that could be saved over the real one.

Method Reference

Method Description
encrypt(string $plaintext, string $context = '') Encrypts and authenticates a string. Returns a URL and cookie-safe string.
decrypt(string $payload, string $context = '') Returns the original string, or null if the payload is invalid, altered, or bound to another context.
sign(string $value, string $context = '') Returns value.signature. The value remains readable.
unsign(string $signed, string $context = '') Returns the original value, or null if the signature doesn't match.
signature(string $data, string $context = '') Returns a detached, URL-safe signature for any data.
verifySignature(string $data, string $signature, string $context = '') Timing-safe check of a detached signature.
UrlSigner::sign(string $url, ?int $ttlSeconds = null) Returns the URL with a signature (and optional expiry) appended.
UrlSigner::verify(string $url) Returns true if the signature is valid and the link has not expired.

Custom Drivers

The default driver is one class behind a small interface. To use something different (for example a key held in a KMS or HSM), implement Rhapsody\Core\Contracts\EncrypterInterface and register it, typically in your application's bootstrap.php:

use Rhapsody\Core\Contracts\EncrypterInterface;
use Rhapsody\Core\Services\Encrypter;

$myDriver = new App\Security\KmsEncrypter();

Encrypter::setInstance($myDriver);
$container->instance(EncrypterInterface::class, $myDriver);

Cookies, signed URLs and encrypted columns all use whichever instance is registered. Encrypter::setInstance() is also the right way to supply a fixed key in unit tests.


What This Is Not For

  • Passwords: never encrypt passwords. Hash them with password_hash() and verify with password_verify().
  • CSRF tokens and sessions: these are random values stored server-side and compared directly. Encrypting them adds cost without adding security.
  • Secrets the browser must read: encryption protects data from the client, but the server holds the key. Data that JavaScript needs (for example in localStorage) cannot be protected this way.

Upgrading

The encrypted payload format changed when this service was introduced (a version byte and a per-cookie context were added). Cookies set by earlier versions of Rhapsody will no longer decrypt: Cookie::get() returns your default value for them, exactly as it does for a tampered cookie, and the next Cookie::set() replaces them. PHP sessions are not affected.

Cookie::setEncryptionKey() has been removed. The key now always comes from APP_KEY (and APP_PREVIOUS_KEYS). If your own code called it, delete the call.