Every SaaS founder I talk to says “we’ll add GDPR later.” This is almost always a mistake — not because GDPR is hard, but because retrofitting compliance onto an existing architecture is far more expensive than building it in from the start.
Here’s what “GDPR-ready architecture” actually means at the code level.
What GDPR Actually Requires (Technically)
Lawyers will give you a long list. Engineers need a shorter one:
- Lawful basis for processing — you know why you’re storing each piece of data
- Data minimisation — you only store what you actually need
- Right to access — users can request their data in a portable format
- Right to erasure — users can request deletion (“right to be forgotten”)
- Breach notification — you know when data was accessed and by whom (audit trail)
- Data at rest encryption — sensitive fields are encrypted in the database
- Data residency — you know where the data is stored geographically
Most of these are architectural decisions, not legal ones.
The Database Layer: Encryption at Rest
Don’t encrypt the whole database — encrypt the sensitive fields. This is cheaper, faster, and more targeted.
# utils/encryption.py
from cryptography.fernet import Fernet, MultiFernet
import os
def get_fernet() -> MultiFernet:
"""
MultiFernet supports key rotation without re-encrypting existing data.
PRIMARY_KEY is active for encryption.
SECONDARY_KEY (optional) is used for decrypting data encrypted with the old key.
"""
primary = Fernet(os.environ["ENCRYPTION_KEY_PRIMARY"])
keys = [primary]
secondary_raw = os.environ.get("ENCRYPTION_KEY_SECONDARY")
if secondary_raw:
keys.append(Fernet(secondary_raw))
return MultiFernet(keys)
def encrypt(value: str) -> str:
return get_fernet().encrypt(value.encode()).decode()
def decrypt(value: str) -> str:
return get_fernet().decrypt(value.encode()).decode()
In the model:
# models/user.py
from sqlalchemy import event
from sqlalchemy.orm import reconstructor
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
_national_id_encrypted: Mapped[Optional[str]] = mapped_column("national_id", String(500))
@property
def national_id(self) -> Optional[str]:
if self._national_id_encrypted is None:
return None
return decrypt(self._national_id_encrypted)
@national_id.setter
def national_id(self, value: Optional[str]) -> None:
self._national_id_encrypted = encrypt(value) if value else None
The application never touches raw PII. It goes in encrypted, comes out decrypted — transparently.
The Audit Trail
Every access to sensitive data must be logged. Not for your benefit — for the user’s. They have the right to know who accessed their data and when.
# models/audit_log.py
class AuditEventType(str, enum.Enum):
# Auth events
USER_LOGIN = "user.login"
USER_LOGIN_FAILED = "user.login_failed"
USER_LOGOUT = "user.logout"
USER_2FA_ENABLED = "user.2fa_enabled"
# Data access
USER_DATA_ACCESSED = "user.data_accessed"
USER_DATA_EXPORTED = "user.data_exported"
USER_DATA_DELETED = "user.data_deleted"
# Admin actions
ADMIN_USER_VIEWED = "admin.user_viewed"
ADMIN_ROLE_CHANGED = "admin.role_changed"
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
event_type: Mapped[AuditEventType] = mapped_column(nullable=False)
actor_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"))
target_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"))
ip_address: Mapped[Optional[str]] = mapped_column(String(45)) # IPv6 max length
user_agent: Mapped[Optional[str]] = mapped_column(String(500))
metadata: Mapped[Optional[dict]] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, index=True)
Critical: audit logs are write-only for application code. No UPDATE, no DELETE. If a log record needs to be “removed”, that itself is a logged event.
The Right to Erasure
“Delete my account” is a GDPR requirement, but it’s not always a DELETE FROM users WHERE id = ?.
You need to think through your data relationships:
# services/gdpr.py
async def erase_user_data(db: AsyncSession, user_id: int, actor_id: int) -> None:
"""
GDPR right to erasure implementation.
We anonymise rather than delete where foreign keys require it
(e.g., audit logs referencing this user must be kept for compliance,
but the personal data within them can be removed).
"""
user = await db.get(User, user_id)
if not user:
raise ValueError(f"User {user_id} not found")
# Anonymise PII fields (keep the record for referential integrity)
user.email = f"deleted_{user_id}@anonymised.invalid"
user.full_name = "Deleted User"
user.national_id = None
user.phone = None
user.is_deleted = True
user.deleted_at = datetime.utcnow()
# Revoke all active sessions
await db.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id)
.values(used=True)
)
# Log the erasure (this itself is a compliance record)
db.add(AuditLog(
event_type=AuditEventType.USER_DATA_DELETED,
actor_user_id=actor_id,
target_user_id=user_id,
metadata={"reason": "gdpr_erasure_request"},
))
await db.commit()
The Right to Data Portability
Users can request their data in a machine-readable format. Build this early:
@router.get("/me/export")
async def export_my_data(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db),
):
"""
GDPR Article 20 — right to data portability.
Returns all personal data in a structured JSON format.
"""
await audit_log(db, AuditEventType.USER_DATA_EXPORTED, actor=current_user)
return {
"export_date": datetime.utcnow().isoformat(),
"data_controller": "uguraslim.com",
"user": {
"id": current_user.id,
"email": current_user.email,
"full_name": current_user.full_name,
"created_at": current_user.created_at.isoformat(),
},
"activity_summary": await get_user_activity_summary(db, current_user.id),
}
Data Residency: Where Are Your Servers?
GDPR doesn’t ban non-EU servers, but it restricts data transfers. The easiest path: keep EU data in the EU.
For the CitizenApp stack:
- Neon Postgres → Frankfurt (eu-central-1) ✓
- Render Docker → Frankfurt region ✓
- Vercel Edge → edge network (content only, no PII) ✓
Document this. When a client asks “where is our data stored?”, you should have a one-line answer.
The GDPR Checklist at Architecture Level
| Requirement | Implementation |
|---|---|
| Lawful basis | Consent field on User model + timestamp |
| Data minimisation | Explicit schema review — delete unused columns |
| Encryption at rest | Fernet on PII fields |
| Access logging | AuditLog table, write-only |
| Right to access | /me/export endpoint |
| Right to erasure | Anonymisation + session revocation |
| Breach detection | Suspicious login alerts + IP rate limiting |
| Data residency | All infra in Frankfurt |
The Cost of Doing It Late
I’ve seen codebases where GDPR was added as an afterthought. The typical cost:
- 2-4 weeks of schema migration work
- Careful data backfill of encrypted fields (risky)
- Retrofitting audit logging on hundreds of endpoints
- Finding all the places PII leaks into logs, caches, error messages
Building it in from day one: maybe 3-5 days of extra upfront work.
It’s not a close comparison.
Building a GDPR-compliant SaaS and want a second opinion on your architecture? Get in touch.