[{"data":1,"prerenderedAt":351},["ShallowReactive",2],{"blog-how-to/database-audit-logs":3},{"id":4,"title":5,"body":6,"category":332,"date":333,"dateModified":333,"description":334,"draft":335,"extension":336,"faq":337,"featured":335,"headerVariant":338,"image":337,"keywords":337,"meta":339,"navigation":340,"ogDescription":341,"ogTitle":337,"path":342,"readTime":337,"schemaOrg":343,"schemaType":344,"seo":345,"sitemap":346,"stem":347,"tags":348,"twitterCard":349,"__hash__":350},"blog/blog/how-to/database-audit-logs.md","How to Set Up Database Audit Logs",{"type":7,"value":8,"toc":316},"minimark",[9,13,17,21,27,30,43,48,51,54,58,78,91,104,117,133,146,159,185,189,217,223,227,232,235,239,242,246,249,253,256,278,297],[10,11],"category-badge",{"category":12},"How-To Guide",[14,15,5],"h1",{"id":16},"how-to-set-up-database-audit-logs",[18,19,20],"p",{},"Track every change for security, debugging, and compliance",[22,23,24],"tldr",{},[18,25,26],{},"TL;DR (25 minutes):\nCreate an audit_logs table, use database triggers to capture INSERT/UPDATE/DELETE operations with user info and timestamps. For compliance, also log SELECT queries on sensitive data. Store logs in a separate schema or database with restricted access. Use application context to track which user made each change.",[18,28,29],{},"Prerequisites:",[31,32,33,37,40],"ul",{},[34,35,36],"li",{},"PostgreSQL, MySQL, or MongoDB database",[34,38,39],{},"Database admin access",[34,41,42],{},"Understanding of your compliance requirements (GDPR, HIPAA, SOC2)",[44,45,47],"h2",{"id":46},"why-this-matters","Why This Matters",[18,49,50],{},"Audit logs answer critical questions: Who changed this data? When was it accessed? Was there unauthorized access? Without audit logs, you can't detect breaches, investigate incidents, or prove compliance.",[18,52,53],{},"Most compliance frameworks (SOC2, HIPAA, GDPR, PCI-DSS) require audit logging. Even if you're not regulated yet, having audit logs helps you understand your application's behavior and recover from mistakes.",[44,55,57],{"id":56},"step-by-step-guide","Step-by-Step Guide",[59,60,62,67],"step",{"number":61},"1",[63,64,66],"h3",{"id":65},"create-the-audit-log-table","Create the audit log table",[68,69,74],"pre",{"className":70,"code":72,"language":73},[71],"language-text","-- PostgreSQL audit log table\nCREATE TABLE audit_logs (\n  id BIGSERIAL PRIMARY KEY,\n  table_name VARCHAR(100) NOT NULL,\n  record_id VARCHAR(100),\n  action VARCHAR(20) NOT NULL,  -- INSERT, UPDATE, DELETE, SELECT\n  old_data JSONB,\n  new_data JSONB,\n  changed_fields TEXT[],\n  user_id VARCHAR(100),\n  user_email VARCHAR(255),\n  ip_address INET,\n  user_agent TEXT,\n  created_at TIMESTAMPTZ DEFAULT NOW()\n);\n\n-- Index for common queries\nCREATE INDEX idx_audit_table_name ON audit_logs(table_name);\nCREATE INDEX idx_audit_user_id ON audit_logs(user_id);\nCREATE INDEX idx_audit_created_at ON audit_logs(created_at);\nCREATE INDEX idx_audit_record_id ON audit_logs(record_id);\n\n-- Prevent modifications to audit logs\nREVOKE UPDATE, DELETE ON audit_logs FROM PUBLIC;\nREVOKE UPDATE, DELETE ON audit_logs FROM myapp_user;\n","text",[75,76,72],"code",{"__ignoreMap":77},"",[59,79,81,85],{"number":80},"2",[63,82,84],{"id":83},"create-the-audit-trigger-function","Create the audit trigger function",[68,86,89],{"className":87,"code":88,"language":73},[71],"-- PostgreSQL audit trigger function\nCREATE OR REPLACE FUNCTION audit_trigger_function()\nRETURNS TRIGGER AS $$\nDECLARE\n  old_data JSONB;\n  new_data JSONB;\n  changed_fields TEXT[];\n  user_info JSONB;\nBEGIN\n  -- Get user context (set by application)\n  user_info := current_setting('app.current_user', true)::JSONB;\n\n  IF TG_OP = 'DELETE' THEN\n    old_data := to_jsonb(OLD);\n    new_data := NULL;\n  ELSIF TG_OP = 'INSERT' THEN\n    old_data := NULL;\n    new_data := to_jsonb(NEW);\n  ELSIF TG_OP = 'UPDATE' THEN\n    old_data := to_jsonb(OLD);\n    new_data := to_jsonb(NEW);\n    -- Calculate changed fields\n    SELECT array_agg(key) INTO changed_fields\n    FROM jsonb_each(to_jsonb(OLD)) AS o(key, value)\n    WHERE to_jsonb(NEW) ->> key IS DISTINCT FROM value::text;\n  END IF;\n\n  INSERT INTO audit_logs (\n    table_name,\n    record_id,\n    action,\n    old_data,\n    new_data,\n    changed_fields,\n    user_id,\n    user_email,\n    ip_address\n  ) VALUES (\n    TG_TABLE_NAME,\n    COALESCE(NEW.id::TEXT, OLD.id::TEXT),\n    TG_OP,\n    old_data,\n    new_data,\n    changed_fields,\n    user_info ->> 'user_id',\n    user_info ->> 'email',\n    (user_info ->> 'ip_address')::INET\n  );\n\n  RETURN COALESCE(NEW, OLD);\nEND;\n$$ LANGUAGE plpgsql;\n",[75,90,88],{"__ignoreMap":77},[59,92,94,98],{"number":93},"3",[63,95,97],{"id":96},"apply-triggers-to-tables","Apply triggers to tables",[68,99,102],{"className":100,"code":101,"language":73},[71],"-- Apply audit trigger to sensitive tables\nCREATE TRIGGER users_audit_trigger\n  AFTER INSERT OR UPDATE OR DELETE ON users\n  FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();\n\nCREATE TRIGGER orders_audit_trigger\n  AFTER INSERT OR UPDATE OR DELETE ON orders\n  FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();\n\nCREATE TRIGGER payments_audit_trigger\n  AFTER INSERT OR UPDATE OR DELETE ON payments\n  FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();\n\n-- Helper function to add audit triggers to any table\nCREATE OR REPLACE FUNCTION add_audit_trigger(target_table TEXT)\nRETURNS VOID AS $$\nBEGIN\n  EXECUTE format('\n    CREATE TRIGGER %I_audit_trigger\n    AFTER INSERT OR UPDATE OR DELETE ON %I\n    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()\n  ', target_table, target_table);\nEND;\n$$ LANGUAGE plpgsql;\n\n-- Usage: SELECT add_audit_trigger('my_table');\n",[75,103,101],{"__ignoreMap":77},[59,105,107,111],{"number":106},"4",[63,108,110],{"id":109},"set-user-context-from-your-application","Set user context from your application",[68,112,115],{"className":113,"code":114,"language":73},[71],"// Node.js/Prisma - set user context before queries\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\n// Middleware to set audit context\nprisma.$use(async (params, next) => {\n  const userContext = getRequestContext(); // From your auth middleware\n\n  if (userContext) {\n    await prisma.$executeRaw`\n      SELECT set_config('app.current_user', ${JSON.stringify({\n        user_id: userContext.userId,\n        email: userContext.email,\n        ip_address: userContext.ipAddress\n      })}, true)\n    `;\n  }\n\n  return next(params);\n});\n\n// Or with raw pg client\nasync function setAuditContext(client, user) {\n  await client.query(\n    `SELECT set_config('app.current_user', $1, true)`,\n    [JSON.stringify({\n      user_id: user.id,\n      email: user.email,\n      ip_address: user.ip\n    })]\n  );\n}\n",[75,116,114],{"__ignoreMap":77},[59,118,120,124,127],{"number":119},"5",[63,121,123],{"id":122},"application-level-audit-logging","Application-level audit logging",[18,125,126],{},"For more control or NoSQL databases, implement at the application layer:",[68,128,131],{"className":129,"code":130,"language":73},[71],"// Application-level audit logging\ninterface AuditLog {\n  tableName: string;\n  recordId: string;\n  action: 'CREATE' | 'UPDATE' | 'DELETE' | 'READ';\n  oldData?: object;\n  newData?: object;\n  userId: string;\n  userEmail: string;\n  ipAddress: string;\n  userAgent: string;\n  timestamp: Date;\n}\n\nclass AuditLogger {\n  async log(entry: Omit) {\n    await prisma.auditLog.create({\n      data: {\n        ...entry,\n        oldData: entry.oldData ? JSON.stringify(entry.oldData) : null,\n        newData: entry.newData ? JSON.stringify(entry.newData) : null,\n        timestamp: new Date()\n      }\n    });\n  }\n\n  async logUpdate(\n    tableName: string,\n    recordId: string,\n    oldData: object,\n    newData: object,\n    user: User,\n    request: Request\n  ) {\n    await this.log({\n      tableName,\n      recordId,\n      action: 'UPDATE',\n      oldData,\n      newData,\n      userId: user.id,\n      userEmail: user.email,\n      ipAddress: request.ip,\n      userAgent: request.headers['user-agent'] || ''\n    });\n  }\n}\n\n// Usage in your API\nasync function updateUser(id: string, data: UpdateUserInput, ctx: Context) {\n  const oldUser = await prisma.user.findUnique({ where: { id } });\n  const newUser = await prisma.user.update({ where: { id }, data });\n\n  await auditLogger.logUpdate('users', id, oldUser, newUser, ctx.user, ctx.request);\n\n  return newUser;\n}\n",[75,132,130],{"__ignoreMap":77},[59,134,136,140],{"number":135},"6",[63,137,139],{"id":138},"query-audit-logs","Query audit logs",[68,141,144],{"className":142,"code":143,"language":73},[71],"-- Find all changes to a specific record\nSELECT * FROM audit_logs\nWHERE table_name = 'users' AND record_id = '123'\nORDER BY created_at DESC;\n\n-- Find all actions by a specific user\nSELECT * FROM audit_logs\nWHERE user_id = 'user_456'\nORDER BY created_at DESC\nLIMIT 100;\n\n-- Find all deletions in the last 24 hours\nSELECT * FROM audit_logs\nWHERE action = 'DELETE'\n  AND created_at > NOW() - INTERVAL '24 hours';\n\n-- Find suspicious activity (bulk operations)\nSELECT user_id, action, table_name, COUNT(*)\nFROM audit_logs\nWHERE created_at > NOW() - INTERVAL '1 hour'\nGROUP BY user_id, action, table_name\nHAVING COUNT(*) > 100;\n",[75,145,143],{"__ignoreMap":77},[59,147,149,153],{"number":148},"7",[63,150,152],{"id":151},"protect-audit-logs","Protect audit logs",[68,154,157],{"className":155,"code":156,"language":73},[71],"-- Create separate schema for audit logs\nCREATE SCHEMA audit;\nALTER TABLE audit_logs SET SCHEMA audit;\n\n-- Create audit admin role\nCREATE ROLE audit_admin NOLOGIN;\nGRANT USAGE ON SCHEMA audit TO audit_admin;\nGRANT SELECT ON audit.audit_logs TO audit_admin;\n-- Note: No INSERT/UPDATE/DELETE granted to humans\n\n-- App user can only insert\nGRANT USAGE ON SCHEMA audit TO myapp_user;\nGRANT INSERT ON audit.audit_logs TO myapp_user;\nGRANT USAGE ON SEQUENCE audit.audit_logs_id_seq TO myapp_user;\n\n-- Prevent truncation\nREVOKE TRUNCATE ON audit.audit_logs FROM PUBLIC;\n\n-- Consider partitioning for large tables\nCREATE TABLE audit.audit_logs_2024_01 PARTITION OF audit.audit_logs\n  FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');\n",[75,158,156],{"__ignoreMap":77},[160,161,162,165],"warning-box",{},[18,163,164],{},"Audit Log Security Requirements:",[31,166,167,170,173,176,179,182],{},[34,168,169],{},"Audit logs should be append-only - no updates or deletes allowed",[34,171,172],{},"Store in separate schema/database with restricted access",[34,174,175],{},"Consider write-once storage (S3 Object Lock, immutable storage)",[34,177,178],{},"Hash or sign entries to detect tampering",[34,180,181],{},"Implement retention policies (but comply with regulations)",[34,183,184],{},"Don't log sensitive data like passwords, even in hashed form",[44,186,188],{"id":187},"how-to-verify-it-worked","How to Verify It Worked",[190,191,192,199,205,211],"ol",{},[34,193,194,198],{},[195,196,197],"strong",{},"Make a change:"," Update a record in an audited table",[34,200,201,204],{},[195,202,203],{},"Query audit logs:"," Verify the change was recorded",[34,206,207,210],{},[195,208,209],{},"Check user context:"," Ensure user ID and IP were captured",[34,212,213,216],{},[195,214,215],{},"Test permissions:"," Verify audit logs can't be modified",[68,218,221],{"className":219,"code":220,"language":73},[71],"-- Make a test change\nUPDATE users SET name = 'Test' WHERE id = 1;\n\n-- Verify it was logged\nSELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1;\n\n-- Should see:\n-- table_name: users\n-- action: UPDATE\n-- old_data: {\"name\": \"Original\"}\n-- new_data: {\"name\": \"Test\"}\n-- user_id: (your user id)\n",[75,222,220],{"__ignoreMap":77},[44,224,226],{"id":225},"common-errors-troubleshooting","Common Errors & Troubleshooting",[228,229,231],"h4",{"id":230},"error-current_setting-returned-null","Error: \"current_setting returned NULL\"",[18,233,234],{},"User context not set. Ensure your application sets app.current_user before queries.",[228,236,238],{"id":237},"audit-trigger-slowing-down-queries","Audit trigger slowing down queries",[18,240,241],{},"Consider async logging: write to a queue (Redis, SQS) and process separately, or use AFTER triggers instead of BEFORE.",[228,243,245],{"id":244},"audit-table-growing-too-large","Audit table growing too large",[18,247,248],{},"Implement partitioning by date and archive old partitions. Consider logging only to sensitive tables.",[228,250,252],{"id":251},"user-id-is-null-in-logs","User ID is NULL in logs",[18,254,255],{},"Check that your middleware sets the user context for all routes, including background jobs.",[257,258,259,266,272],"faq-section",{},[260,261,263],"faq-item",{"question":262},"Should I log SELECT queries?",[18,264,265],{},"For most tables, no - it's too verbose. For sensitive data (medical records, financial data, PII), yes. Use PostgreSQL's pgAudit extension or implement targeted application-level logging.",[260,267,269],{"question":268},"How long should I keep audit logs?",[18,270,271],{},"Depends on regulations: HIPAA requires 6 years, PCI-DSS requires 1 year, GDPR varies. When in doubt, keep for 7 years. Use tiered storage (hot → warm → cold) to manage costs.",[260,273,275],{"question":274},"What about Supabase/Firebase audit logging?",[18,276,277],{},"Supabase has pg_audit extension and logs. Firebase has built-in audit logging in the Console. Both can be enhanced with application-level logging for more detail.",[18,279,280,283,288,289,288,293],{},[195,281,282],{},"Related guides:",[284,285,287],"a",{"href":286},"/blog/how-to/database-backups","Database Backups"," ·\n",[284,290,292],{"href":291},"/blog/how-to/postgresql-roles","PostgreSQL Roles",[284,294,296],{"href":295},"/blog/how-to/database-encryption","Database Encryption",[298,299,300,306,311],"related-articles",{},[301,302],"related-card",{"description":303,"href":304,"title":305},"Complete guide to GitHub Secrets for GitHub Actions. Store API keys, access tokens, and sensitive data securely in your ","/blog/how-to/github-secrets","How to Use GitHub Secrets for Actions",[301,307],{"description":308,"href":309,"title":310},"Prevent accidental commits of API keys, .env files, and credentials. Complete guide to configuring .gitignore for sensit","/blog/how-to/gitignore-secrets","How to Gitignore Sensitive Files",[301,312],{"description":313,"href":314,"title":315},"Step-by-step guide to password hashing with bcrypt and Argon2. Why you should never use MD5 or SHA, and how to implement","/blog/how-to/hash-passwords-securely","How to Hash Passwords Securely",{"title":77,"searchDepth":317,"depth":317,"links":318},2,[319,320,330,331],{"id":46,"depth":317,"text":47},{"id":56,"depth":317,"text":57,"children":321},[322,324,325,326,327,328,329],{"id":65,"depth":323,"text":66},3,{"id":83,"depth":323,"text":84},{"id":96,"depth":323,"text":97},{"id":109,"depth":323,"text":110},{"id":122,"depth":323,"text":123},{"id":138,"depth":323,"text":139},{"id":151,"depth":323,"text":152},{"id":187,"depth":317,"text":188},{"id":225,"depth":317,"text":226},"how-to","2026-01-09","Step-by-step guide to implementing database audit logging. Track who accessed what data, when, and detect unauthorized access or data breaches.",false,"md",null,"yellow",{},true,"Implement comprehensive database audit logging for security and compliance.","/blog/how-to/database-audit-logs","[object Object]","HowTo",{"title":5,"description":334},{"loc":342},"blog/how-to/database-audit-logs",[],"summary_large_image","7Z6EuF2kddHE6J3j815Ckm65xNQ8TE4FCKqyeHhQ_OI",1775843928759]