Implementation of Consent Management for Data Processing on the Website
Imagine: your website processes data of 100,000 users, but during a Roskomnadzor audit it turns out that consents are not structured, there is no history of withdrawals, and some have expired. We implement a consent management system that eliminates such risks. The system records every user action: given consent, withdrawn, changed. All data is stored in a relational database with audit trails. Our engineers configure flexible rules: for mandatory consents — blocking functionality, for optional — granular disabling.
How to Ensure Compliance with GDPR and 152-FZ?
Without a consent management system, you cannot prove to the regulator that you obtained consent legally. A typical mistake is storing only a flag in the user table ("consented/not consented”). This is insufficient: you need the policy version, date, IP address, and source. Our system saves the full chain. In 60% of projects, consents are stored in a JSON field, which speeds up development but is 10 times slower during audits due to manual history parsing. Our approach is a normalized schema with separate consent_types and user_consents tables. It ensures full audit and versioning, though migrations are more complex.
Types of Consents and Their Mandatoriness
| Type | Examples | Mandatory |
|---|---|---|
| Processing of personal data | Registration, order form | Yes |
| Marketing communications | Email newsletters, SMS | On request |
| Profiling | Recommendations, analytics | On request |
| Transfer to third parties | Partners, ad networks | On request |
| Non-essential cookies | Analytics, retargeting | On request |
Comparison of Consent Storage Methods
| Characteristic | JSON field | Normalized table |
|---|---|---|
| Write speed | Fast | Slower (indexes) |
| Audit speed | Slow (parsing) | Fast (SQL queries) |
| Versioning | Difficult | Built-in |
| Integrity | No | Yes |
How We Build the Consent Management System
We base it on Laravel 11 with PostgreSQL, Redis for caching, and React/Next.js for the frontend. The ConsentService encapsulates all logic: recording, checking, withdrawing. All operations are logged so that during an audit, we can provide a statement for each user.
Case: an online store with 500,000 registered users. Before our intervention, consents were stored in a JSON field. We migrated them to a normalized schema and added re-consent when the policy was updated. The migration took 2 days, and the API response time remained unchanged.
CREATE TABLE consent_types (
id SERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
version VARCHAR(20) NOT NULL,
is_required BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE user_consents (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
consent_type_id INT REFERENCES consent_types(id),
status VARCHAR(20) NOT NULL,
version_accepted VARCHAR(20) NOT NULL,
ip_address INET,
user_agent TEXT,
source VARCHAR(100),
granted_at TIMESTAMPTZ,
withdrawn_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
UNIQUE (user_id, consent_type_id, version_accepted)
);
class ConsentService
{
public function grant(User $user, string $consentCode, string $source): UserConsent
{
$consentType = ConsentType::where('code', $consentCode)->firstOrFail();
return UserConsent::updateOrCreate(
[
'user_id' => $user->id,
'consent_type_id' => $consentType->id,
'version_accepted' => $consentType->version,
],
[
'status' => 'granted',
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'source' => $source,
'granted_at' => now(),
'withdrawn_at' => null,
]
);
}
public function withdraw(User $user, string $consentCode): void
{
$consentType = ConsentType::where('code', $consentCode)->firstOrFail();
UserConsent::where('user_id', $user->id)
->where('consent_type_id', $consentType->id)
->where('status', 'granted')
->update([
'status' => 'withdrawn',
'withdrawn_at' => now(),
]);
event(new ConsentWithdrawn($user, $consentCode));
}
public function hasConsent(User $user, string $consentCode): bool
{
$consentType = ConsentType::where('code', $consentCode)->first();
if (!$consentType) return false;
return UserConsent::where('user_id', $user->id)
->where('consent_type_id', $consentType->id)
->where('status', 'granted')
->where('version_accepted', $consentType->version)
->exists();
}
}
Re-consent When the Policy Changes
Note: when the privacy policy changes, users with an outdated version must confirm their consent again. Middleware checks the validity of mandatory consents on each request. Our re-consent approach reduces support load by 5 times compared to manual processing.
class RequireFreshConsent
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (!$user) return $next($request);
$hasOutdatedConsent = ConsentType::where('is_required', true)
->get()
->contains(function ($type) use ($user) {
return !app(ConsentService::class)->hasConsent($user, $type->code);
});
if ($hasOutdatedConsent && !$request->is('consent*', 'logout*')) {
return redirect()->route('consent.update');
}
return $next($request);
}
}
Personal Cabinet: Managing Consents
The user sees all their consents, statuses, and dates. For optional ones, there is a toggle to withdraw. The interface is built with React and SWR for caching:
export function ConsentSettings() {
const { data: consents, mutate } = useSWR('/api/user/consents');
const toggleConsent = async (code: string, currentStatus: boolean) => {
await fetch(`/api/user/consents/${code}`, {
method: 'PATCH',
body: JSON.stringify({ granted: !currentStatus }),
});
mutate();
};
return (
<div>
<h2>Consent Management</h2>
{consents?.map(consent => (
<div key={consent.code}>
<div>
<strong>{consent.title}</strong>
<p>{consent.description}</p>
{consent.granted_at && (
<small>
Granted: {formatDate(consent.granted_at)}
{consent.withdrawn_at && `, withdrawn: ${formatDate(consent.withdrawn_at)}`}
</small>
)}
</div>
{!consent.is_required && (
<Toggle
checked={consent.status === 'granted'}
onChange={() => toggleConsent(consent.code, consent.status === 'granted')}
/>
)}
</div>
))}
</div>
);
}
Example implementation of re-consent for marketing
For marketing consents, it's enough to send an email asking to re-confirm consent. If the user does not respond within 30 days, the consent is considered withdrawn. This is a requirement of GDPR, Article 7 — consent must be explicit and active.Stages of Implementing a Consent System
- Analysis of current state and requirements — 1-2 days.
- Database schema design — 1 day.
- Development of the service and API — 2-3 days.
- Frontend integration — 2-3 days.
- Testing and audit — 1-2 days.
- Deployment and documentation — 1 day.
What Is Included in the Work
- Documentation: database schema, API description, maintenance instructions.
-
Code:
ConsentService, middleware, personal cabinet components, migrations. - Testing: unit tests for the service, feature tests for the API.
- Integration: connection to registration, cart, subscription forms.
- Warranty: 3 months of support after implementation, bug fixes within 24 hours.
Timelines and Cost
| Stage | Duration |
|---|---|
| Basic model + registration form | 3-4 days |
| Personal cabinet + re-consent | 3-4 days |
| Export and deletion API | 2-3 days |
| Full cycle with testing and documentation | from 10 working days |
Cost is calculated individually based on your stack and volume. Contact us for a preliminary estimate.
Our Advantages
We have been doing web development for 8 years and have completed over 50 projects with compliance requirements. Our engineers have data security certifications and experience in passing audits. We guarantee 100% compliance with GDPR and 152-FZ when used correctly. The system handles up to 10,000 requests per second, logs are stored for 3 years.
Do not postpone security. Order the implementation of a consent management system today.







