Consent records
This page covers consent records: the record schema, how long each piece of consent data is kept, the consent route and its signed cookie, the built-in stores, and what remains your responsibility.
A regulator can ask you to demonstrate that a visitor consented. With a consent endpoint configured, ConsentLayer posts each choice to a route on your server, which appends a record to your database and answers with a signed cookie. The tracking route can then read consent from that cookie instead of the request body, so a request claiming consent the visitor never gave is treated as denied.
Records
Section titled “Records”Each choice appends one record. Records are never updated or deleted by ConsentLayer.
| Field | Column | Type | Content |
|---|---|---|---|
consentId |
consent_id |
string |
A random UUID identifying the browser. It is kept in the signed cookie, so all of one browser’s choices share it. |
createdAt |
created_at |
Date |
When the server received the choice. |
consent |
consent |
ConsentState |
Every category, such as { "preferences": false, "analytics": true, "marketing": false }, plus sale_of_data when it is set. |
policyVersion |
policy_version |
string |
The privacy policy version, from the route’s options. |
userId |
user_id |
string, optional |
The signed-in user, when your userId option returns one. |
restored |
restored |
boolean |
true when the browser sent a choice it had already stored, because its consent cookie was missing. The visitor did not choose at createdAt; their original choice is an earlier record under another consentId. See Lost cookie. |
A choice is recorded when it changes the stored consent: through the banner,
analytics.consent.update() or setConsent(), or through a consent
adapter’s subscription, such as Shopify’s visitorConsentCollected event.
A stored choice is also sent again, as a restored record, when its cookie
is lost.
Nothing is recorded when a stored choice is restored on a later visit, when a
choice is read from an adapter at bootstrap, when another tab changes the
stored choice, or when a submitted choice is identical to the current one.
How long data is kept
Section titled “How long data is kept”ConsentLayer never deletes a consent record. Every other piece of consent data has a fixed lifetime:
| Data | Where | Kept for |
|---|---|---|
| Consent records | Your database, through the store | Until you delete them. See Retention and deletion. |
The consent cookie, __Host-consentlayer_proof |
The visitor’s browser | 400 days from the last recorded choice (Max-Age=34560000), the longest lifetime browsers allow, or until the visitor clears cookies. |
The consentId |
Inside the consent cookie | As long as the cookie. Each recorded choice reuses the ID from a valid cookie; without one, a new ID is generated. |
| The visitor’s choice | localStorage, under consentlayer_consent |
Until the visitor changes it or clears site data, unless you set consent.maxAgeDays, after which they are asked again. |
The cookie and the stored choice expire independently: with the defaults, a choice can outlive its cookie. See the lost-cookie case under The consent cookie.
Generate a secret of at least 32 characters, for example with
openssl rand -base64 32, and set it as CONSENT_SECRET on the server only.
The consent route and the tracking route must use the same secret. Both
check it at the start of each request rather than when they are created, so
next build succeeds where the secret exists only at runtime.
import { createConsentHandler, postgresStore } from 'consentlayer/server'import { sql } from '@/lib/db'import { auth } from '@/lib/auth'import { POLICY_VERSION } from '@/lib/policy'
export const POST = createConsentHandler({ store: postgresStore(sql), secret: process.env.CONSENT_SECRET, policyVersion: POLICY_VERSION, userId: async () => (await auth())?.user.id, // categories: ['functional'], if you declared custom categories})export const analytics = createAnalytics({ server: { endpoint: '/api/analytics', consentEndpoint: '/api/consent' }, // ...})export const POST = createAnalyticsHandler({ destinations: [ metaCapi({ pixelId: process.env.NEXT_PUBLIC_FACEBOOK_PIXEL_ID, accessToken: process.env.META_TOKEN }), ], consentCookie: { secret: process.env.CONSENT_SECRET },})server.consentEndpoint must be an HTTPS URL or a path starting with /;
undefined or '' turns recording off, and any other value throws when
createAnalytics is called. Each recorded choice is
posted as { "consent": { … } } with fetch. A network failure or non-2xx
response is reported to onError with scope consent-adapter and provider ID
consent-endpoint; the choice still applies in the browser. A failed
lost-cookie restore is reported with scope
server-forward and the message
Restoring the consent cookie at <consentEndpoint> failed.
createConsentHandler options
| Option | Type | Default | Description |
|---|---|---|---|
store |
ConsentStore |
Required | Where records are appended. |
secret |
string | undefined |
Required | The HMAC key, at least 32 characters. |
policyVersion |
string |
Required | Written to every record and to the cookie. Pass the same value to createAnalytics({ consent: { policyVersion } }); a new major version asks visitors again. See Asking again. |
categories |
string[] |
[] |
Your custom categories, as passed to createAnalytics({ categories }). Categories not listed are dropped from the record. |
userId |
(request: Request) => string | undefined | Promise<string | undefined> |
None | Returns the signed-in user from your session. The request body is never used for it. |
cookie |
string |
'__Host-consentlayer_proof' |
The cookie name. The default is 'consentlayer_proof' on localhost, 127.0.0.1 and [::1]. |
onError |
(error: unknown) => void |
None | Receives unexpected errors, such as a store failure. |
The consent route
Section titled “The consent route”createConsentHandler returns a web-standard
(request: Request) => Promise<Response>. For each request it:
- Checks the origin as the tracking route does. A request without an
OriginorRefererheader, or from another origin, gets 403. - Reads the body. A
Content-Typeother thanapplication/jsongets 415, a body over 4 KB (4,096 bytes) gets 413, and an empty body or invalid JSON gets 400. - Validates
consent: every built-in category and every category incategoriesmust be a boolean, andsale_of_data, if present, a boolean. Otherwise it answers 400. - Reuses the
consentIdfrom a valid existing cookie, or generates one withcrypto.randomUUID(). - Calls
userId, then appends the record to the store, withrestoredset when the body hasrestored: true. - Answers 204 with the signed cookie.
Responses have no body. If the store or userId throws, the error is passed
to onError, the response is 500 and no cookie is set.
The consent cookie
Section titled “The consent cookie”Set-Cookie: __Host-consentlayer_proof=<payload>.<signature>; Path=/; Max-Age=34560000; HttpOnly; SameSite=Lax; SecureSecure is left out when the request’s host is localhost, 127.0.0.1 or
[::1], because Safari does not store Secure cookies on
http://localhost. Browsers accept a __Host- cookie only with Secure, so
there the default name is consentlayer_proof. The handler sets the cookie
only when the request’s Origin equals the origin of the request URL, so the
host it checks is always the one the visitor’s browser sees. Behind a proxy
that rewrites the host or protocol, the handler answers 403 instead; configure
the proxy, or your framework, to keep the public origin in the request URL.
Max-Age is 400 days, the longest lifetime browsers allow. The payload is the
base64url-encoded JSON of { consentId, consent, policyVersion }; the
signature is the base64url-encoded HMAC-SHA-256 of the encoded payload, keyed
with the secret. Both use the URL-safe alphabet without padding.
With consentCookie set, the cookie can only narrow the consent in the
request body: a category counts as granted only when both the cookie and the
body grant it. A missing cookie, a malformed value or a signature that does not
verify counts as every category denied. In your own server code, read the
verified contents with readConsentCookie(request, { secret }), which
resolves to { consentId, consent, policyVersion } or null.
Edge cases
- Before the cookie arrives. Events forwarded between a choice and the consent route’s response carry no cookie yet, so they count as denied.
- Lost cookie. The cookie can be cleared or expire while the browser
still stores the choice. The tracking route then answers with the header
consentlayer-consent: missing, and the browser posts the stored choice toserver.consentEndpointwithrestored: true, once per page load. The consent route records it with a newconsentIdand sets a new cookie. The event that revealed the loss counts as denied, as do events sent before the new cookie arrives. Withoutserver.consentEndpoint, events count as denied until the visitor makes a choice that differs from the stored one. - Secret rotation. Changing the secret invalidates every existing cookie,
so each browser restores its cookie as for a lost cookie, adding one
restoredrecord per browser. - Missing or short secret.
createConsentHandler()answers every request with 500 and passes[consentlayer] the consent secret is not setor[consentlayer] the consent secret needs at least 32 characterstoonError. The check runs before the body is read, so no record is stored.createAnalyticsHandler({ consentCookie })answers 500 for a short secret; with the secret unset it logs aconsole.warnonce when created and treats every event as denied, so environments withoutCONSENT_SECRETsend nothing. - Policy version. The cookie is not checked against the current
policyVersion; a cookie signed under an earlier version still verifies.
Stores
Section titled “Stores”A store implements one method, append(record: ConsentRecord): Promise<void>.
Every SQL store writes to the same consent_records table:
create table consent_records ( id bigint generated always as identity primary key, consent_id text not null, created_at timestamptz not null, consent jsonb not null, policy_version text not null, user_id text, restored boolean not null default false);create index on consent_records (consent_id);create index on consent_records (user_id);To make the table append-only in the database as well, revoke update and
delete on it from the role your application connects with.
Postgres
Section titled “Postgres”postgresStore(sql) takes any sql tagged template that returns a promise:
postgres.js (postgres(url)), Neon (neon(url)) or Vercel Postgres (sql
from @vercel/postgres). It runs a parameterized insert, casting
created_at to timestamptz and consent to jsonb. The table name is
fixed to consent_records, because tagged templates cannot parameterize
identifiers portably.
Supabase
Section titled “Supabase”import { createClient } from '@supabase/supabase-js'const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
supabaseStore(supabase) // or supabaseStore(supabase, { table: 'consent_records' })supabaseStore inserts one row with snake_case columns into consent_records,
or the table named by table, and throws
[consentlayer] supabase insert failed, with Supabase’s error as cause,
when the insert returns an error. Create the table above in the SQL editor
and enable row level security with no policies, so the browser’s anon key can
neither read nor write it. Use the service role key on the server only.
Prisma
Section titled “Prisma”model ConsentRecord { id BigInt @id @default(autoincrement()) consentId String @map("consent_id") createdAt DateTime @map("created_at") @db.Timestamptz consent Json policyVersion String @map("policy_version") userId String? @map("user_id") restored Boolean @default(false)
@@index([consentId]) @@index([userId]) @@map("consent_records")}prismaStore(prisma.consentRecord)prismaStore calls create on the model with the record’s camelCase fields,
setting userId to null when there is none.
Drizzle
Section titled “Drizzle”import { bigint, boolean, index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const consentRecords = pgTable( 'consent_records', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), consentId: text('consent_id').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), consent: jsonb('consent').notNull(), policyVersion: text('policy_version').notNull(), userId: text('user_id'), restored: boolean('restored').notNull().default(false), }, (table) => [index().on(table.consentId), index().on(table.userId)],)drizzleStore(db, consentRecords)drizzleStore calls db.insert(table).values(…) with the record’s camelCase
fields, setting userId to null when there is none.
Upstash Redis and Vercel KV
Section titled “Upstash Redis and Vercel KV”import { Redis } from '@upstash/redis'upstashStore(Redis.fromEnv()) // or upstashStore(kv) with @vercel/kvEach consent ID has a list, consent:<consentId>. Every choice is appended
with RPUSH as the record’s JSON, with createdAt as an ISO 8601 string, so
the list is ordered oldest first. Change the key prefix with
upstashStore(redis, { prefix }).
Your own
Section titled “Your own”import type { ConsentStore } from 'consentlayer/server'
const store: ConsentStore = { async append(record) { await myDb.insert(record) },}A rejected promise makes the consent route answer 500 without setting the cookie.
Retention and deletion
Section titled “Retention and deletion”ConsentLayer only appends records. Choosing how long to keep them, deleting old ones and deleting a person’s records when they ask are your responsibility, so schedule the deletion yourself, for example in a cron job.
With the Postgres, Prisma, Drizzle and Supabase stores, records live in the
consent_records table, unless you renamed it. To delete records older than a
retention period:
delete from consent_records where created_at < now() - interval '3 years';A record proves the consent that a browser currently has, so deleting by age alone can remove the proof for a choice still in force. To keep each browser’s latest record regardless of its age:
delete from consent_records rwhere r.created_at < now() - interval '3 years' and exists ( select 1 from consent_records newer where newer.consent_id = r.consent_id and newer.created_at > r.created_at );To delete a person’s records, find a browser’s records by consent_id, which
you can read in your own route with
readConsentCookie(request, { secret }), and a
signed-in user’s by user_id:
delete from consent_records where consent_id = $1 or user_id = $2;With the Upstash Redis and Vercel KV store, each browser’s records are one
list, so DEL consent:<consentId> deletes them. Redis cannot expire single
entries of a list; to delete by age, read the list with LRANGE, keep the
entries whose createdAt is recent enough, and write them back.