Home / Databases / SQL Server

DatabasesHow-toAdvanced

Who changed that job? Auditing SQL Agent job changes with SQL Server Audit

SQL Server Agent records that a job changed but not who changed it or what changed. Use a database audit specification on msdb's job procedures to capture who did it, keep script snapshots to capture what changed, and know the limits of triggers on msdb tables.

Deepak Bhardwaj3 min read

Applies toSQL Server 2016–2025 (database audit specifications in all editions from 2016 SP1), Azure SQL Managed Instance

What you get for free

SELECT name, date_created, date_modified, version_number
FROM msdb.dbo.sysjobs ORDER BY date_modified DESC;

version_number goes up with each change. That tells you that something changed and when, but not who or what.

Who: audit the msdb procedures

Every SSMS change to a job goes through procedures such as sp_update_job, sp_update_jobstep, sp_add_jobstep and sp_delete_job. Audit their execution.

USE master;
CREATE SERVER AUDIT AgentChanges
  TO FILE (FILEPATH = N'D:\SQLAudit\', MAXSIZE = 100 MB, MAX_ROLLOVER_FILES = 20)
  WITH (ON_FAILURE = CONTINUE);
ALTER SERVER AUDIT AgentChanges WITH (STATE = ON);

USE msdb;
CREATE DATABASE AUDIT SPECIFICATION AgentJobChanges
FOR SERVER AUDIT AgentChanges
  ADD (EXECUTE ON OBJECT::dbo.sp_add_job        BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_update_job     BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_delete_job     BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_add_jobstep    BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_update_jobstep BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_delete_jobstep BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_add_jobschedule    BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_update_schedule    BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_attach_schedule    BY public),
  ADD (EXECUTE ON OBJECT::dbo.sp_detach_schedule    BY public)
WITH (STATE = ON);

Read it:

SELECT event_time, server_principal_name, client_ip, application_name, object_name, statement
FROM sys.fn_get_audit_file(N'D:\SQLAudit\AgentChanges*.sqlaudit', DEFAULT, DEFAULT)
WHERE object_name LIKE 'sp[_]%job%' OR object_name LIKE 'sp[_]%schedule%'
ORDER BY event_time DESC;

The statement column contains the call with its parameters, for example EXEC msdb.dbo.sp_update_job @job_id=..., @enabled=0.

Tip

Add the same audit spec to your server build. It costs very little because job changes are rare.

What: keep script snapshots

The audit shows who ran sp_update_jobstep, but to compare old and new commands you need the before state. A nightly job that scripts all jobs to files gives you a diff:

Import-Module dbatools
$out = "D:\JobScripts\$(Get-Date -f yyyy-MM-dd)"
New-Item -ItemType Directory $out -Force | Out-Null
Get-DbaAgentJob -SqlInstance localhost | ForEach-Object {
  $_ | Export-DbaScript -FilePath (Join-Path $out ("{0}.sql" -f ($_.Name -replace '[\\/:*?"<>|]','_'))) -NoPrefix | Out-Null
}

Commit the folder to a local Git repo and git diff shows exactly what changed each day.

Option: triggers on msdb tables

Some teams put triggers on msdb.dbo.sysjobs and sysjobsteps to log changes into a table.

Pros Cons
Captures old and new values in one place Modifying system database objects is unsupported territory
Easy to query Triggers may be lost or break on upgrades/CU installs
A failing trigger can block job changes

If you do it, keep triggers tiny, log only, and include them in your post-patching checks.

Alerting on sensitive changes

For the most important events (a job disabled, a step command changed on a backup job), see the job enable/disable alert post. That alert runs on the audit or snapshot data and emails the team.

Prevent untracked changes

  • Few people with rights to change production jobs (msdb roles, not sysadmin for everyone).
  • Changes deployed from scripts in source control.
  • Audit as a safety net, not the main control.