In the modern landscape of SaaS and enterprise software, SOC 2 (System and Organization Controls 2) is no longer a “nice to have” — it is a foundational requirement for doing business. At its core, SOC 2 asks a simple but difficult question: How can you prove that only authorized users accessed specific data and how do you protect that data if your perimeter fails?
Most developers believe that standard Role-Based Access Control (RBAC) and database-level encryption-at-rest (like AWS RDS encryption) are enough. They aren’t. Standard RDS encryption protects your data if someone physically steals a hard drive from a data center but it does nothing if an attacker gains access to your running application or your primary database credentials.
In this exhaustive guide we will move beyond standard security and implement Application-Level Envelope Encryption. By the end of this article you will understand how to build a Symfony application where data is cryptographically isolated at the record level.
The Architectural Strategy - Hybrid Cryptography
Before writing a single line of code we must define our cryptographic strategy. We face a classic dilemma:
- Symmetric Encryption (AES): Fast, efficient for large files but requires sharing the secret key. If you use one key for everyone, a single leak compromises the entire system.
- Asymmetric Encryption (RSA): Great for sharing secrets securely using Public/Private key pairs, but incredibly slow for large data and has strict limits on payload size.
The Hybrid Solution - The Envelope Pattern
Envelope Encryption combines the best of both worlds.
- Step A: We generate a unique, random, one-time-use Data Encryption Key (DEK) for every single document. We use AES-256-GCM to encrypt the document content with this DEK.
- Step B: We take that small DEK and “wrap” (encrypt) it using the recipient’s RSA Public Key (The KEK — Key Encrypting Key).
- Step C: We store the encrypted content and the “wrapped” DEK (the Envelope) in the database.
To decrypt, the user uses their RSA Private Key to unwrap the DEK, then uses the DEK to decrypt the document. The document itself is never encrypted with a shared password.
Deep Dive into the Primitives
Standard AES modes like CBC (Cipher Block Chaining) are vulnerable to “Bit-Flipping” attacks. An attacker might not be able to read your data, but they can modify the cipher text in a way that changes the decrypted values (e.g., changing a price from 100 to 900).
GCM (Galois/Counter Mode) is an Authenticated Encryption mode. It generates an Authentication Tag alongside the cipher text. If even a single bit of the encrypted data is modified, the decryption will fail. This provides Integrity and Authenticity, which are critical for SOC 2.
When wrapping keys, we use OAEP (Optimal Asymmetric Encryption Padding). Older padding schemes like PKCS#1 v1.5 are vulnerable to “padding oracle” attacks. OAEP adds a layer of randomness that ensures the same DEK encrypted twice with the same Public Key will result in different cipher texts.
Data Modeling for Cryptographic Isolation
We need a database schema that supports this hybrid model. We will use Symfony’s Doctrine ORM with Attribute-based mapping.
The User Entity - The Key Custodian
Each user must have an asymmetric identity. While the Private Key should be managed by a secure session vault or HSM, the Public Key is stored in our database.
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User implements UserInterface
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email = null;
// The KEK (Key Encrypting Key) Public Component
#[ORM\Column(type: 'text')]
private ?string $publicKey = null;
// ... standard getters and setters ...
}
The Document Entity - The Encrypted Payload
The document doesn’t care who owns it, it only cares about its encrypted bits. We also store a Server-Side Envelope. This is a copy of the DEK encrypted with a Master Key. Why? Because SOC 2 requires “Availability.” If a user loses their private key, you need a way for authorized administrators to recover data or for automated backup processes to verify integrity.
#[ORM\Entity]
#[ORM\Table(name: 'documents')]
class Document
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
// The AES-256-GCM Encrypted Content
#[ORM\Column(type: 'text')]
private ?string $encryptedContent = null;
// The DEK encrypted with the Server's Master Key (Backup/Audit)
#[ORM\Column(type: 'string', length: 255)]
private ?string $serverEncryptedDek = null;
}
The DocumentShare Entity - The Envelope Store
This is a many-to-many relationship table on steroids. It maps a document to a user and stores the DEK wrapped specifically for that user.
#[ORM\Entity]
#[ORM\Table(name: 'document_shares')]
class DocumentShare
{
#[ORM\ManyToOne(targetEntity: Document::class, inversedBy: 'shares')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Document $document = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $sharedWith = null;
// The DEK encrypted specifically for $sharedWith's RSA Public Key
#[ORM\Column(type: 'text')]
private ?string $userEncryptedDek = null;
}
Implementing the Cryptographic Engine
In Symfony, we follow the Dependency Inversion Principle. We don’t want our business logic to depend on the openssl_* functions directly. Instead, we define an interface.
The Interface - EnvelopeCryptoServiceInterface
This interface defines the contract for all cryptographic operations. If you later decide to move to AWS KMS or HashiCorp Vault, you simply implement this interface and swap the service in services.yaml.
interface EnvelopeCryptoServiceInterface {
public function generateDek(): string;
public function encryptContent(string $plainText, string $dek): string;
public function decryptContent(string $payload, string $dek): ?string;
public function encryptDekForUser(string $dek, string $publicKey): string;
public function decryptDekWithUserKey(string $wrappedDek, string $privateKey): string;
}
The Implementation - EnvelopeCryptoService
Using PHP’s native OpenSSL extension, we implement the logic. Note the handling of the IV (Initialization Vector) and Tag. We concatenate them with the cipher text and base64-encode the entire package for easy storage in text fields.
public function encryptContent(string $plainText, string $dek): string {
$ivLength = openssl_cipher_iv_length('aes-256-gcm');
$iv = random_bytes($ivLength);
// GCM handles the $tag automatically
$cipherText = openssl_encrypt($plainText, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $iv, $tag);
// Package: [IV][TAG][CIPHERTEXT]
return base64_encode($iv . $tag . $cipherText);
}
The Orchestrator - DocumentManager
The DocumentManager is the most important part of our business logic. It ensures that whenever a document is created, the cryptographic chain is completed correctly and atomically.
It has three main responsibilities:
- Creation: Orchestrates DEK generation, content encryption and the creation of the primary owner’s envelope.
- Sharing: Retrieves the DEK (using the server’s backup envelope) and re-wraps it for a new recipient.
- Reading: Locates the user’s specific envelope and performs the multi-stage decryption.
Notice that the DocumentManager does not call flush(). It only prepares the entities. The caller (Controller or Command) is responsible for the transaction. This follows the Unit of Work pattern and ensures that we don’t end up with “half-created” records if a network error occurs.
Logical Security - Symfony Voters
Even with perfect encryption, we still need application-level logic. If Alice tries to view Bob’s document, we shouldn’t even attempt to perform the expensive RSA decryption. We use a Symfony Voter.
The DocumentVoter checks two conditions:
- Does the user own the document?
- Is there a DocumentShare record mapping the document to the user?
By placing this check in a Voter, we can use the #[IsGranted] attribute in our controllers, keeping them incredibly clean.
#[Route('/documents/{id}', name: 'document_view')]
#[IsGranted('DOCUMENT_VIEW', subject: 'document')]
public function view(Document $document, DocumentManagerInterface $manager): Response {
// If the code execution gets here, we KNOW the user has logical access.
// Now we just need to verify they have the cryptographic key.
}
Conclusion
We have successfully moved from a “Trust the Database” model to a “Trust the Math” model. Even if an intruder dumps your documents table, they see nothing but base64 gibberish. They cannot decrypt the documents without the users’ RSA Private Keys, which aren’t in the database.
However, we still have two problems:
- Log Leakage: If we log every change, do the logs themselves contain sensitive data?
- Key Centralization: If the server’s Master Key is stolen, all document DEKs can be recovered.
In Part 2, we will solve these issues by implementing Asynchronous Audit Isolation and a Sharded Key Management Service (KMS) and share full implementation GitHub repo.
Let’s Connect!
If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:
- LinkedIn: [https://www.linkedin.com/in/matthew-mochalkin/]
- X (Twitter): [https://x.com/MattLeads]
- Telegram: [https://t.me/MattLeads]
- GitHub: [https://github.com/mattleads]
Top comments (0)