Signal Desktop
Signal Desktop — Messages
Overview
The messages table in the Signal Desktop SQLCipher database is the primary forensic artifact for message content recovery. It contains all sent and received messages, system events (key changes, group membership changes, profile updates), call history entries, and expired disappearing message records. Once the database is decrypted, the body column contains fully readable plaintext message text.
Unlike Apple Messages (chat.db), Signal does not maintain a separate deleted messages reference table. When a message is deleted, the row is removed from the messages table entirely. The presence of an isErased = 1 flag indicates the message body was cleared (expiration timer reached or manual erase) but the message row still exists.
Timestamp Format
All timestamps in the Signal Desktop messages table use Unix milliseconds — the number of milliseconds since January 1, 1970 00:00:00 UTC. This is the JavaScript/Electron standard epoch.
This differs from Apple's Core Data epoch (used in Messages, FaceTime, Contacts, and Calendar), which counts seconds or nanoseconds from January 1, 2001.
Conversion formula:
Unix seconds = sent_at / 1000
-- Convert sent_at to human-readable UTC
SELECT datetime(sent_at / 1000, 'unixepoch') AS sent_utc
FROM messages;
Key timestamp columns:
| Column | Meaning |
|---|---|
sent_at | When the message was sent (sender's clock, Unix ms) |
received_at | When this device received the message (local clock, Unix ms) |
serverTimestamp | Signal server timestamp (Unix ms) |
expirationStartTimestamp | When the disappearing timer began counting (Unix ms) |
received_at is particularly valuable for timeline reconstruction as it reflects the local device clock at the moment of receipt, independent of the sender's clock accuracy.
Messages Table Schema
| Column | Type | Description |
|---|---|---|
id | TEXT | UUID primary key |
conversationId | TEXT | FK to conversations.id |
sent_at | INTEGER | Unix timestamp ms (sender's clock) |
received_at | INTEGER | Unix timestamp ms (receiver's device clock) |
serverTimestamp | INTEGER | Signal server timestamp ms |
body | TEXT | Plaintext message content (NULL for media-only or erased) |
type | TEXT | Message type (see types table below) |
source | TEXT | Sender phone number (E.164, legacy field) |
sourceUuid | TEXT | Sender's Signal UUID |
hasAttachments | INTEGER | 1 if the message has attachments |
hasFileAttachments | INTEGER | 1 if the message has non-visual file attachments |
hasVisualMediaAttachments | INTEGER | 1 if the message has image or video attachments |
isErased | INTEGER | 1 if the message body was erased (disappearing or manual delete) |
expireTimer | INTEGER | Disappearing message duration in seconds (0 = no timer) |
expirationStartTimestamp | INTEGER | Unix ms when the expiration timer started |
readStatus | INTEGER | 0 = unread, 1 = read |
json | TEXT | Full message object JSON (reactions, quotes, mentions, link previews) |
The json column is particularly rich — Signal stores the complete JavaScript message object as a JSON blob alongside the relational columns. This includes quoted message references, emoji reactions, body range mentions, and link preview metadata that do not appear in dedicated columns.
Message Types
The type column identifies the nature of each row:
| Type | Description |
|---|---|
incoming | Message received from a contact |
outgoing | Message sent by the local user |
keychange | System event: a contact's identity key changed |
group-v2-change | System event: group membership or settings changed |
profile-change | System event: a contact's profile name changed |
call-history | Record of a voice or video call attempt |
story | Signal Story post (Signal Desktop 6.0+) |
timer-notification | Disappearing message timer changed for a conversation |
change-number-notification | A contact changed their phone number |
verified-change | Safety number verification status changed |
System-type messages (anything other than incoming and outgoing) are valuable forensically because they are generated by the Signal application and cannot be edited by the user. They create a timestamped audit trail of security events, group membership changes, and profile updates.
View-Once Messages
Signal supports view-once media (sometimes called "disappearing media") where an image or video can only be viewed once by the recipient. After viewing:
- The
bodycolumn is cleared - The
isErasedcolumn is set to 1 - The associated attachment file may be deleted from the
attachments.noindexdirectory
The message row itself remains in the messages table, providing evidence that a view-once media exchange occurred, including the timestamp and participants.
Disappearing Messages
Disappearing messages are controlled by expireTimer (per conversation, inherited by messages) and the expirationStartTimestamp column:
expireTimer = 0means no disappearing timer is set for this messageexpireTimer > 0contains the duration in seconds (e.g., 3600 = 1 hour, 86400 = 1 day)expirationStartTimestamprecords when the timer started counting — typically when the recipient viewed the message- When the timer expires, Signal clears the
bodycolumn and setsisErased = 1 - The message row is retained even after erasure
Forensic implication: The presence of a message with isErased = 1 proves the message existed and establishes the approximate timeframe (via sent_at and expirationStartTimestamp). Investigators can calculate when the timer expired as expirationStartTimestamp + (expireTimer * 1000).
Reactions
In modern Signal Desktop versions, emoji reactions are stored in the json column of the target message rather than in a separate table. The JSON structure for reactions:
{
"reactions": [
{
"emoji": "👍",
"fromId": "11111111-2222-3333-4444-555555555555",
"targetTimestamp": 1700000000000,
"timestamp": 1700000001234
}
]
}
| Field | Description |
|---|---|
emoji | The reaction emoji character |
fromId | Signal UUID of the user who reacted |
targetTimestamp | sent_at value of the message being reacted to |
timestamp | Unix ms when the reaction was sent |
Quoted Messages (Replies)
When a user replies to a specific message, the reply message contains a quote reference in the json column:
{
"quote": {
"id": 1700000000000,
"authorUuid": "11111111-2222-3333-4444-555555555555",
"text": "The original message text"
}
}
The id field in a quote is the sent_at millisecond timestamp of the quoted message — not a UUID. This allows correlation back to the original message.
Key SQL Queries
All Messages with Conversation Context
SELECT
datetime(m.sent_at / 1000, 'unixepoch') AS sent_utc,
datetime(m.received_at / 1000, 'unixepoch') AS received_utc,
m.type,
m.body,
c.name AS conversation_name,
c.e164 AS phone_number,
m.sourceUuid AS sender_uuid,
m.hasAttachments,
m.isErased
FROM messages m
JOIN conversations c ON c.id = m.conversationId
ORDER BY m.sent_at;
Outgoing Messages Only
SELECT
datetime(sent_at / 1000, 'unixepoch') AS sent_utc,
body,
conversationId
FROM messages
WHERE type = 'outgoing'
ORDER BY sent_at;
Erased Messages (Disappearing Message Evidence)
SELECT
id,
conversationId,
datetime(sent_at / 1000, 'unixepoch') AS sent_utc,
datetime(expirationStartTimestamp / 1000, 'unixepoch') AS expiration_started_utc,
expireTimer,
datetime((expirationStartTimestamp + expireTimer * 1000) / 1000, 'unixepoch') AS expired_at_utc,
hasAttachments,
hasVisualMediaAttachments
FROM messages
WHERE isErased = 1
ORDER BY sent_at;
Security Events (Key Changes, Group Changes)
SELECT
datetime(sent_at / 1000, 'unixepoch') AS event_utc,
type,
conversationId,
sourceUuid,
body
FROM messages
WHERE type IN ('keychange', 'group-v2-change', 'profile-change', 'verified-change', 'change-number-notification')
ORDER BY sent_at;
Messages with Attachments
SELECT
m.id,
datetime(m.sent_at / 1000, 'unixepoch') AS sent_utc,
m.type,
m.body,
m.hasAttachments,
m.hasFileAttachments,
m.hasVisualMediaAttachments,
c.name AS conversation
FROM messages m
JOIN conversations c ON c.id = m.conversationId
WHERE m.hasAttachments = 1
ORDER BY m.sent_at DESC;
Message Volume Timeline (Daily)
SELECT
date(sent_at / 1000, 'unixepoch') AS day,
sum(CASE WHEN type = 'outgoing' THEN 1 ELSE 0 END) AS sent,
sum(CASE WHEN type = 'incoming' THEN 1 ELSE 0 END) AS received
FROM messages
WHERE type IN ('incoming', 'outgoing')
GROUP BY day
ORDER BY day;
Full-Text Search in Message Bodies
SELECT
datetime(sent_at / 1000, 'unixepoch') AS sent_utc,
type,
body,
conversationId
FROM messages
WHERE body LIKE '%keyword%'
ORDER BY sent_at;
Forensic Analysis Notes
- body column is plaintext after decryption: Once SQLCipher is opened, the
bodycolumn contains fully readable message text. There is no per-message encryption layer. - sourceUuid vs source: The
sourceUuidfield (Signal UUID) is the modern identifier. Thesourcefield (phone number) is a legacy field populated for older messages. For messages received from newer Signal clients,sourcemay be empty whilesourceUuidis populated. - Call history: Calls are recorded as
type = 'call-history'rows in the messages table, not in a separate table. These records include the call direction and outcome. - System messages cannot be deleted by users: Rows with types such as
keychange,group-v2-change, andverified-changeare system-generated events that users cannot delete through the Signal Desktop interface, providing a reliable audit trail. - Story messages: Signal Stories (introduced in Signal Desktop 6.0) appear in the messages table with
type = 'story'and include astoryIdcolumn reference. Stories expire after 24 hours but the database row may persist.