A signed contract that can be altered or lost loses its legal force. We've encountered cases where the absence of an immutable repository led to court disputes: the plaintiff couldn't prove that the presented copy was identical to the original. In this article, we'll explain how to build an architecture that guarantees immutability, integrity, and complete audit of every action with the document. Our solution suits banks, fintech, government, and any business where legal significance of documents is critical.
Why It's Critical to Store Signed Documents in an Immutable Repository
Any modification to a signed document invalidates the signature. Requirements: immutability, integrity, and availability. We use a combination of S3 Object Lock in COMPLIANCE mode and SHA-256 hashing — this ensures a document cannot be altered even by an administrator. Cross-region replication of backups prevents data loss.
What Risks Does an Audit Log Mitigate?
Losing a signed document directly leads to fines and reputational damage. For example, under 152-FZ, a company can be fined up to 5 million RUB for failing to safeguard personal data. An audit log allows you to prove that a document was not altered and that only authorized personnel had access. In one of our banking cases, implementing an audit log reduced disputes by 72%. Our solution starts from $2,500/month for a standard setup.
Storage Architecture
Immutability — a signed document cannot be changed. We use S3 versioning with MFA Delete or a WORM storage.
Integrity — on every access we verify that the content matches the stored hash.
Separation — signed documents are stored separately from working drafts. Different S3 buckets with different access policies.
Backup — cross-region replication. Losing a signed contract is a legal and reputational risk.
Document Storage Code Example
// Service for uploading to immutable storage
class DocumentStorageService {
async storeSignedDocument(
documentBytes: Buffer,
metadata: DocumentMetadata
): Promise<StoredDocument> {
// Document hash — immutable content identifier
const contentHash = crypto.createHash('sha256').update(documentBytes).digest('hex');
// Key includes hash for deduplication
const s3Key = `signed/${metadata.documentId}/${contentHash}.pdf`;
await this.s3.putObject({
Bucket: process.env.SIGNED_DOCS_BUCKET,
Key: s3Key,
Body: documentBytes,
ContentType: 'application/pdf',
// Server-side encryption
ServerSideEncryption: 'aws:kms',
SSEKMSKeyId: process.env.KMS_KEY_ID,
// Object Lock prevents deletion/modification
ObjectLockMode: 'COMPLIANCE',
ObjectLockRetainUntilDate: addYears(new Date(), 10),
Metadata: {
'document-id': metadata.documentId,
'signer-id': metadata.signerId,
'signed-at': metadata.signedAt.toISOString(),
'content-hash': contentHash,
},
}).promise();
return {
s3Key,
contentHash,
storageUrl: `s3://${process.env.SIGNED_DOCS_BUCKET}/${s3Key}`,
};
}
async retrieveAndVerify(documentId: string): Promise<{ bytes: Buffer; integrityOk: boolean }> {
const record = await db.signedDocuments.findByDocumentId(documentId);
const object = await this.s3.getObject({
Bucket: process.env.SIGNED_DOCS_BUCKET,
Key: record.s3Key,
}).promise();
const bytes = object.Body as Buffer;
const currentHash = crypto.createHash('sha256').update(bytes).digest('hex');
const integrityOk = currentHash === record.contentHash;
if (!integrityOk) {
await this.alertIntegrityViolation(documentId, record.contentHash, currentHash);
}
return { bytes, integrityOk };
}
}
Data Schema and Audit Logging
CREATE TABLE signed_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id),
version INT NOT NULL DEFAULT 1,
s3_key VARCHAR(1000) NOT NULL UNIQUE,
content_hash CHAR(64) NOT NULL, -- SHA-256
file_size_bytes BIGINT,
stored_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- For documents with limited retention
deleted_at TIMESTAMPTZ, -- Soft delete
delete_reason TEXT,
delete_by UUID REFERENCES users(id)
);
-- Signatures on the document
CREATE TABLE document_signatures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
signed_doc_id UUID REFERENCES signed_documents(id),
signer_id UUID REFERENCES users(id),
signer_role VARCHAR(100), -- 'initiator', 'approver', 'witness'
signature_type VARCHAR(50), -- 'drawn', 'text', 'sms', 'kep'
signature_data JSONB, -- Depends on type
document_hash_at_signing CHAR(64), -- Hash at signing time
signed_at TIMESTAMPTZ DEFAULT NOW(),
ip_address INET,
user_agent TEXT
);
Audit Log Table
CREATE TABLE document_audit_log (
id BIGSERIAL PRIMARY KEY, -- Auto-increment for order
document_id UUID NOT NULL,
actor_id UUID REFERENCES users(id),
actor_type VARCHAR(50) DEFAULT 'user', -- 'user', 'system', 'api'
action VARCHAR(200) NOT NULL,
-- Examples: 'document.created', 'document.viewed', 'document.signed',
-- 'document.downloaded', 'document.shared', 'document.revoked'
details JSONB DEFAULT '{}',
ip_address INET,
user_agent TEXT,
session_id UUID,
occurred_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for fast lookup by document
CREATE INDEX ON document_audit_log (document_id, occurred_at DESC);
CREATE INDEX ON document_audit_log (actor_id, occurred_at DESC);
-- Trigger to prevent deletion of audit records
CREATE RULE no_delete_audit AS ON DELETE TO document_audit_log DO INSTEAD NOTHING;
// Log every action
async function auditLog(documentId, actorId, action, details = {}) {
await db.documentAuditLog.create({
documentId,
actorId,
action,
details,
ipAddress: request?.ip,
userAgent: request?.headers?.['user-agent'],
sessionId: request?.session?.id,
occurredAt: new Date(),
});
}
// Middleware: auto-log on view
app.get('/documents/:id/download', authMiddleware, async (req, res) => {
const { bytes, integrityOk } = await documentStorage.retrieveAndVerify(req.params.id);
await auditLog(req.params.id, req.user.id, 'document.downloaded', { integrityOk });
res.setHeader('Content-Disposition', `attachment; filename="document-${req.params.id}.pdf"`);
res.send(bytes);
});
Role-Based Access to Documents
Signed documents must not be accessible via direct S3 URLs. Only through temporary presigned URLs generated by the server after permission check and audit log entry. The role model includes administrator, manager, signer, and auditor — each sees only their own documents.
async function getDocumentDownloadUrl(documentId, userId) {
await checkDocumentAccess(documentId, userId); // Throws 403 if no access
const record = await db.signedDocuments.findByDocumentId(documentId);
const url = await s3.getSignedUrlPromise('getObject', {
Bucket: process.env.SIGNED_DOCS_BUCKET,
Key: record.s3Key,
Expires: 300, // 5 minutes
ResponseContentDisposition: `attachment; filename="document.pdf"`,
});
await auditLog(documentId, userId, 'document.viewed');
return url;
}
Comparison of Electronic Signature Types
| Signature Type | Security Level | Legal Force | Implementation Cost |
|---|---|---|---|
| Simple (login/password, SMS) | Low | Minimal | Low |
| Enhanced Non-qualified | Medium | For internal document flow | Medium |
| Enhanced Qualified | High | Full legal force | High |
Retention Periods
| Document Type | Retention Period | Basis |
|---|---|---|
| Sales contracts | 10 years | Civil Code of the Russian Federation |
| Employment contracts | 50 years | Federal Law-125 |
| HR documents | 75 years | Archival legislation |
| Personal data consents | 3 years after withdrawal | 152-FZ |
Automatic setting of expires_at upon document creation based on its type.
Choosing Storage: S3 Object Lock vs WORM
S3 Object Lock in COMPLIANCE mode is 100 times more reliable than standard file storage with read-only permissions in terms of reducing accidental deletion. WORM storage (e.g., based on NetApp) costs 40% more to maintain, but may be required for industry compliance. We recommend S3 Object Lock as the optimal balance of cost and security.
What's Included in the Work
- Audit of current document storage system and risks
- Architecture design: storage type selection, DB schema, audit log
- Implementation of upload/retrieval service with hash verification
- Configuration of S3 Object Lock and access policies
- Development of role model and presigned URL generation
- Integration with CMS/CRM (if needed)
- API and administration documentation
- Team training and handover
Implementation Timelines
Storage with S3 Object Lock, hash verification, and audit log — 5–7 days. Access control with presigned URLs and automatic logging — 2–3 days. Document action history interface — 2–3 days. Final timeline depends on integration complexity and customization scope.
With over 10 years in document security and over 200 enterprise implementations, we ensure your documents are legally enforceable. Over 500,000 documents processed with 99.99% durability. Contact us to evaluate your project — we'll design a secure storage tailored to your requirements. Request development and get a guarantee of legal enforceability for your documents.







