v0.1.0-draft AI Drafted

Microsoft 365 Hardening Guide

Productivity Last updated: 2025-02-05

Comprehensive security hardening for Microsoft 365, Exchange Online, SharePoint, Teams, and OneDrive

Overview

Microsoft 365 is the world’s most widely deployed productivity suite, with over 345 million paid seats across enterprises globally. As the central collaboration platform for email, documents, and communication, M365 represents a critical attack surface. The January 2024 Midnight Blizzard breach demonstrated how a single misconfigured test tenant without MFA enabled nation-state actors to access Microsoft’s own corporate email, including senior leadership and cybersecurity teams.

Intended Audience

  • Security engineers managing Microsoft 365 environments
  • IT administrators configuring tenant security
  • GRC professionals assessing cloud productivity compliance
  • Third-party risk managers evaluating M365 integrations

How to Use This Guide

  • L1 (Baseline): Essential controls for all organizations
  • L2 (Hardened): Enhanced controls for security-sensitive environments
  • L3 (Maximum Security): Strictest controls for regulated industries

Scope

This guide covers Microsoft 365 tenant-level security configurations including Entra ID (Azure AD) authentication policies, Exchange Online protection, SharePoint/OneDrive data security, Teams governance, and integration security. Azure infrastructure hardening is covered in a separate guide.


Table of Contents

  1. Authentication & Access Controls
  2. Network Access Controls
  3. OAuth & Integration Security
  4. Data Security
  5. Monitoring & Detection
  6. Third-Party Integration Security
  7. Compliance Quick Reference

1. Authentication & Access Controls

1.1 Enforce Phishing-Resistant MFA for All Users

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 6.3, 6.5
NIST 800-53 IA-2(1), IA-2(6)
CIS M365 Benchmark 1.1.1, 1.1.3

Description

Require phishing-resistant MFA (FIDO2 security keys, Windows Hello for Business, or certificate-based authentication) for all users. Microsoft reports that over 99.9% of compromised accounts had MFA disabled.

Rationale

Why This Matters:

  • Password spray attacks remain the most common attack vector against M365
  • Legacy MFA methods (SMS, voice call) are vulnerable to SIM swapping and social engineering
  • Phishing-resistant MFA eliminates real-time phishing proxy attacks (Evilginx, Modlishka)

Attack Prevented: Password spray, credential stuffing, real-time phishing, MFA fatigue attacks

Real-World Incidents:

  • January 2024 Midnight Blizzard Breach: Russian APT29 used password spray to compromise a legacy test tenant without MFA, gaining access to Microsoft corporate email including senior leadership
  • October 2024 Midnight Blizzard Phishing Campaign: Targeted thousands of users across 100+ organizations using RDP configuration file attachments

Prerequisites

  • Microsoft Entra ID P1 or P2 license (for Conditional Access)
  • FIDO2-compatible security keys for privileged users
  • Global Administrator or Security Administrator role
  • User inventory for phased rollout planning

ClickOps Implementation

Step 1: Enable Security Defaults (Basic Protection)

  1. Navigate to: Microsoft Entra admin centerIdentityOverviewProperties
  2. Click Manage security defaults
  3. Set Security defaults to Enabled
  4. Click Save

Note: Security Defaults enforces MFA for all users but lacks granular control. For enterprise environments, use Conditional Access instead.

Step 2: Create Conditional Access Policy for MFA

  1. Navigate to: Microsoft Entra admin centerProtectionConditional Access
  2. Click + Create new policy
  3. Configure:
    • Name: Require MFA for all users
    • Users: All users (exclude break-glass accounts)
    • Cloud apps: All cloud apps
    • Conditions: Any location
    • Grant: Require multifactor authentication
  4. Set Enable policy to On
  5. Click Create

Step 3: Configure Authentication Strength for Phishing Resistance

  1. Navigate to: ProtectionAuthentication methodsAuthentication strengths
  2. Click + New authentication strength
  3. Name: “Phishing-Resistant MFA”
  4. Select only:
    • FIDO2 security key
    • Windows Hello for Business
    • Certificate-based authentication
  5. Save and apply to Conditional Access policies for admins

Time to Complete: ~45 minutes (policy) + user enrollment time

Code Implementation

Code Pack: Terraform
hth-microsoft-365-1.1-enforce-phishing-resistant-mfa.tf View source on GitHub ↗
# Look up break-glass accounts to exclude from Conditional Access
data "azuread_user" "break_glass" {
  count               = length(var.break_glass_account_upns)
  user_principal_name = var.break_glass_account_upns[count.index]
}

# Conditional Access policy: Require MFA for all users
resource "azuread_conditional_access_policy" "require_mfa" {
  display_name = "HTH: Require MFA for all users"
  state        = var.mfa_policy_state

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    client_app_types = ["all"]
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

# L2+: Require phishing-resistant authentication strength (FIDO2, WHfB, CBA)
resource "azuread_authentication_strength_policy" "phishing_resistant" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name = "HTH: Phishing-Resistant MFA"
  description  = "Requires FIDO2 security keys, Windows Hello for Business, or certificate-based authentication"

  allowed_combinations = [
    "fido2",
    "windowsHelloForBusiness",
    "x509CertificateMultiFactor",
  ]
}

# L2+: Conditional Access policy requiring phishing-resistant MFA for admins
resource "azuread_conditional_access_policy" "require_phishing_resistant_mfa" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name = "HTH: Require phishing-resistant MFA for admins"
  state        = "enabled"

  conditions {
    users {
      included_roles = [
        # Global Administrator
        "62e90394-69f5-4237-9190-012177145e10",
        # Security Administrator
        "194ae4cb-b126-40b2-bd5b-6091b380977d",
        # Privileged Role Administrator
        "e8611ab8-c189-46e8-94e1-60213ab1f814",
      ]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    client_app_types = ["all"]
  }

  grant_controls {
    operator                          = "OR"
    authentication_strength_policy_id = azuread_authentication_strength_policy.phishing_resistant[0].id
  }
}
Code Pack: API Script
hth-microsoft-365-1.1-enforce-mfa-azcli.sh View source on GitHub ↗
# Create Conditional Access policy via Graph API
az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
  --headers "Content-Type=application/json" \
  --body '{
    "displayName": "Require MFA for all users",
    "state": "enabled",
    "conditions": {
      "users": {
        "includeUsers": ["All"],
        "excludeUsers": ["BREAK_GLASS_ACCOUNT_ID"]
      },
      "applications": {
        "includeApplications": ["All"]
      }
    },
    "grantControls": {
      "operator": "OR",
      "builtInControls": ["mfa"]
    }
  }'
Code Pack: CLI Script
hth-microsoft-365-1.1-enforce-mfa-powershell.ps1 View source on GitHub ↗
# Install Microsoft Graph PowerShell module
Install-Module Microsoft.Graph -Scope CurrentUser

# Connect with required permissions
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess", "Application.Read.All"

# Create Conditional Access policy requiring MFA
$params = @{
    displayName = "Require MFA for all users"
    state = "enabled"
    conditions = @{
        users = @{
            includeUsers = @("All")
            excludeUsers = @("BREAK_GLASS_ACCOUNT_ID")
        }
        applications = @{
            includeApplications = @("All")
        }
    }
    grantControls = @{
        operator = "OR"
        builtInControls = @("mfa")
    }
}

New-MgIdentityConditionalAccessPolicy -BodyParameter $params
Code Pack: DB Query
hth-microsoft-365-1.01-detect-single-factor-signins.kql View source on GitHub ↗
SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationRequirement == "singleFactorAuthentication"
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress, Location

Validation & Testing

How to verify the control is working:

  1. Sign in as a test user and verify MFA prompt appears
  2. Attempt sign-in from unmanaged device - MFA should be required
  3. Review sign-in logs for MFA enforcement: Entra admin centerMonitoringSign-in logs
  4. Run: Get-MgIdentityConditionalAccessPolicy | Where-Object {$_.State -eq "enabled"}

Expected result: All user sign-ins require MFA, sign-in logs show “MFA requirement satisfied”

Monitoring & Maintenance

Ongoing monitoring:

  • Monitor sign-in logs for MFA bypass attempts
  • Alert on sign-ins without MFA from Conditional Access exclusions
  • Track MFA registration completion rates

Maintenance schedule:

  • Weekly: Review MFA registration status for new users
  • Monthly: Audit Conditional Access policy exclusions
  • Quarterly: Test break-glass account access procedures

Operational Impact

Aspect Impact Level Details
User Experience Medium Users must complete MFA on each sign-in or trusted session expiry
System Performance None No performance impact
Maintenance Burden Low Minimal ongoing maintenance after initial deployment
Rollback Difficulty Easy Disable policy in Conditional Access console

Potential Issues:

  • Users without MFA-capable devices: Provide hardware security keys
  • Legacy applications: May require app passwords (discouraged) or modern auth upgrade

Rollback Procedure:

  1. Navigate to Conditional Access → Select policy → Set state to Off
  2. Or via PowerShell: Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $policyId -State "disabled"

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access security
NIST 800-53 IA-2(1) Multi-factor authentication to privileged accounts
ISO 27001 A.9.4.2 Secure log-on procedures
CIS M365 1.1.1 Ensure MFA is enabled for all users

1.2 Block Legacy Authentication Protocols

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 4.2
NIST 800-53 IA-2, AC-17
CIS M365 Benchmark 1.1.2

Description

Block legacy authentication protocols (POP3, IMAP, SMTP AUTH, Basic Auth) that cannot enforce MFA and are commonly exploited in password spray attacks.

Rationale

Why This Matters:

  • Legacy protocols bypass MFA entirely
  • Password spray attacks frequently target legacy auth endpoints
  • Basic Authentication is deprecated by Microsoft

Attack Prevented: Password spray via legacy protocols, credential theft replay

Real-World Incidents:

  • Midnight Blizzard (2024): Initial access via password spray would have been blocked if legacy auth was disabled

Prerequisites

  • Inventory of applications using legacy auth
  • Migration plan for legacy applications to modern auth (OAuth 2.0)

ClickOps Implementation

Step 1: Block via Conditional Access

  1. Navigate to: Microsoft Entra admin centerProtectionConditional Access
  2. Click + Create new policy
  3. Configure:
    • Name: Block legacy authentication
    • Users: All users
    • Cloud apps: All cloud apps
    • ConditionsClient apps: Select only “Exchange ActiveSync clients” and “Other clients”
    • Grant: Block access
  4. Set Enable policy to On
  5. Click Create

Step 2: Disable SMTP AUTH at Tenant Level

  1. Navigate to: Exchange admin centerSettingsMail flow
  2. Disable SMTP AUTH at the organization level

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Terraform
hth-microsoft-365-1.2-block-legacy-authentication.tf View source on GitHub ↗
# Conditional Access policy: Block legacy authentication protocols
# Blocks POP3, IMAP, SMTP AUTH, Basic Auth that bypass MFA
resource "azuread_conditional_access_policy" "block_legacy_auth" {
  display_name = "HTH: Block legacy authentication"
  state        = var.legacy_auth_policy_state

  conditions {
    users {
      included_users = ["All"]
    }

    applications {
      included_applications = ["All"]
    }

    # Target only legacy auth client types
    client_app_types = ["exchangeActiveSync", "other"]
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}
Code Pack: CLI Script
hth-microsoft-365-1.2-block-legacy-auth.ps1 View source on GitHub ↗
# Connect to Exchange Online
Connect-ExchangeOnline

# Disable SMTP AUTH for all mailboxes
Get-Mailbox -ResultSize Unlimited | Set-CASMailbox -SmtpClientAuthenticationDisabled $true

# Verify
Get-CASMailbox -ResultSize Unlimited | Select-Object DisplayName, SmtpClientAuthenticationDisabled
# Create Conditional Access policy to block legacy authentication via Graph
$params = @{
    displayName = "Block legacy authentication"
    state = "enabled"
    conditions = @{
        users = @{
            includeUsers = @("All")
        }
        applications = @{
            includeApplications = @("All")
        }
        clientAppTypes = @("exchangeActiveSync", "other")
    }
    grantControls = @{
        operator = "OR"
        builtInControls = @("block")
    }
}

New-MgIdentityConditionalAccessPolicy -BodyParameter $params

Validation & Testing

  1. Attempt POP3/IMAP connection - should fail
  2. Review sign-in logs for blocked legacy auth attempts
  3. Verify legitimate applications still function via modern auth

Expected result: Legacy authentication attempts blocked, modern auth sign-ins succeed

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access security
NIST 800-53 IA-2 Identification and authentication
CIS M365 1.1.2 Ensure legacy authentication is blocked

1.3 Implement Privileged Identity Management (PIM)

Profile Level: L2 (Hardened)

Framework Control
CIS Controls 5.4, 6.8
NIST 800-53 AC-2(7), AC-6(1)
CIS M365 Benchmark 1.1.4

Description

Enable just-in-time privileged access using Microsoft Entra Privileged Identity Management (PIM) to eliminate standing admin privileges and enforce approval workflows.

Rationale

Why This Matters:

  • Standing privileges create persistent attack surface
  • Compromised admin accounts provide unlimited access duration
  • PIM provides audit trail for all privilege elevation

Attack Prevented: Privilege persistence, lateral movement, insider threats

Real-World Incidents:

  • Midnight Blizzard: Persistent OAuth app permissions allowed extended access; time-limited roles would have reduced blast radius

Prerequisites

  • Microsoft Entra ID P2 license
  • Global Administrator or Privileged Role Administrator
  • Defined approval workflow owners

ClickOps Implementation

Step 1: Enable PIM for Directory Roles

  1. Navigate to: Microsoft Entra admin centerIdentity governancePrivileged Identity Management
  2. Click Azure AD rolesRoles
  3. Select Global Administrator
  4. Click SettingsEdit
  5. Configure:
    • Activation maximum duration: 2 hours
    • Require justification on activation: Yes
    • Require approval to activate: Yes (for highly privileged roles)
    • Require MFA on activation: Yes
  6. Click Update

Step 2: Convert Permanent Assignments to Eligible

  1. In PIM → Azure AD roles → Assignments
  2. For each permanent Global Admin, click Update → Change to Eligible
  3. Set eligibility period (e.g., 1 year with renewal review)

Time to Complete: ~1 hour for initial configuration

Code Implementation

Code Pack: CLI Script
hth-microsoft-365-1.3-privileged-identity-management.ps1 View source on GitHub ↗
# Connect with PIM permissions
Connect-MgGraph -Scopes "RoleManagement.ReadWrite.Directory"

# Get Global Administrator role
$role = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq 'Global Administrator'"

# Create eligible assignment (replace user ID)
$params = @{
    action = "adminAssign"
    justification = "Initial PIM setup"
    roleDefinitionId = $role.Id
    directoryScopeId = "/"
    principalId = "USER_OBJECT_ID"
    scheduleInfo = @{
        startDateTime = (Get-Date).ToUniversalTime().ToString("o")
        expiration = @{
            type = "afterDuration"
            duration = "P365D"
        }
    }
}

New-MgRoleManagementDirectoryRoleEligibilityScheduleRequest -BodyParameter $params

Validation & Testing

  1. Verify no standing Global Admin assignments (all eligible)
  2. Test PIM activation workflow as eligible admin
  3. Confirm MFA and justification required on activation
  4. Review PIM audit logs for activation events

Expected result: Admins must activate roles on-demand with MFA, approval, and justification

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.2 Privileged access management
NIST 800-53 AC-2(7) Privileged user accounts
ISO 27001 A.9.2.3 Privileged access rights management

1.4 Configure Break-Glass Emergency Access Accounts

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 5.1
NIST 800-53 AC-2
CIS M365 Benchmark 1.1.5

Description

Create and secure emergency access accounts that are excluded from Conditional Access and MFA policies to ensure tenant recovery if normal admin access is lost.

Rationale

Why This Matters:

  • Conditional Access misconfiguration can lock out all admins
  • Federation failures can prevent normal authentication
  • Emergency accounts provide last-resort access

Best Practice:

  • Minimum 2 break-glass accounts
  • Cloud-only (no federation dependency)
  • Excluded from all Conditional Access policies
  • Long, complex passwords stored securely offline
  • Monitored for any usage

Prerequisites

  • Global Administrator access
  • Secure offline storage for credentials (safe, vault)
  • Monitoring/alerting configured

ClickOps Implementation

Step 1: Create Break-Glass Accounts

  1. Navigate to: Microsoft Entra admin centerUsersAll users
  2. Click + New userCreate new user
  3. Configure:
    • Username: emergency-admin-01@yourdomain.onmicrosoft.com (use .onmicrosoft.com domain)
    • Name: Emergency Admin 01
    • Password: Generate 64+ character random password
  4. Assign Global Administrator role
  5. Repeat for second account (emergency-admin-02)

Step 2: Exclude from Conditional Access

  1. Edit each Conditional Access policy
  2. Under UsersExclude, add both break-glass accounts
  3. Save all policies

Step 3: Configure Monitoring

  1. Navigate to: Microsoft Entra admin centerMonitoringDiagnostic settings
  2. Create alert rule for any sign-in from break-glass accounts

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-microsoft-365-1.4-break-glass-emergency-access.tf View source on GitHub ↗
locals {
  # Only create break-glass accounts if domain and passwords are provided
  create_break_glass = (
    var.break_glass_account_domain != "" &&
    length(var.break_glass_account_passwords) >= 2
  )
}

# Break-glass emergency access account 1
# Cloud-only, excluded from all Conditional Access policies
resource "azuread_user" "break_glass_01" {
  count = local.create_break_glass ? 1 : 0

  user_principal_name = "emergency-admin-01@${var.break_glass_account_domain}"
  display_name        = "Emergency Admin 01"
  password            = var.break_glass_account_passwords[0]
  account_enabled     = true

  # Prevent password expiry on emergency accounts
  disable_password_expiration = true
  disable_strong_password     = false
}

# Break-glass emergency access account 2
resource "azuread_user" "break_glass_02" {
  count = local.create_break_glass ? 1 : 0

  user_principal_name = "emergency-admin-02@${var.break_glass_account_domain}"
  display_name        = "Emergency Admin 02"
  password            = var.break_glass_account_passwords[1]
  account_enabled     = true

  disable_password_expiration = true
  disable_strong_password     = false
}

# Activate Global Administrator role for break-glass assignment
resource "azuread_directory_role" "global_admin_break_glass" {
  count = local.create_break_glass ? 1 : 0

  template_id = "62e90394-69f5-4237-9190-012177145e10"
}

# Assign Global Administrator to break-glass account 1
resource "azuread_directory_role_assignment" "break_glass_01_admin" {
  count = local.create_break_glass ? 1 : 0

  role_id             = azuread_directory_role.global_admin_break_glass[0].template_id
  principal_object_id = azuread_user.break_glass_01[0].object_id
}

# Assign Global Administrator to break-glass account 2
resource "azuread_directory_role_assignment" "break_glass_02_admin" {
  count = local.create_break_glass ? 1 : 0

  role_id             = azuread_directory_role.global_admin_break_glass[0].template_id
  principal_object_id = azuread_user.break_glass_02[0].object_id
}

Validation & Testing

  1. Verify break-glass accounts can sign in bypassing Conditional Access
  2. Test sign-in generates alert
  3. Confirm credentials are securely stored offline
  4. Document account usage procedure

Expected result: Emergency accounts accessible when needed, usage immediately alerted


2. Network Access Controls

2.1 Configure Trusted Locations and Named Locations

Profile Level: L2 (Hardened)

Framework Control
CIS Controls 13.5
NIST 800-53 AC-4, SC-7

Description

Define trusted IP ranges (corporate networks, VPN egress) and use them in Conditional Access policies to restrict access or reduce MFA friction for trusted locations.

Rationale

Why This Matters:

  • Reduces MFA fatigue for users on corporate networks
  • Enables blocking access from high-risk countries
  • Provides additional signal for risk-based policies

ClickOps Implementation

Step 1: Create Named Location

  1. Navigate to: Microsoft Entra admin centerProtectionConditional AccessNamed locations
  2. Click + IP ranges location
  3. Configure:
    • Name: Corporate Network
    • Mark as trusted location: Yes
    • IP ranges: Add corporate egress IPs (e.g., 203.0.113.0/24)
  4. Click Create

Step 2: Block High-Risk Countries

  1. Click + Countries location
  2. Name: “Blocked Countries”
  3. Select countries where your organization has no business presence
  4. Create Conditional Access policy blocking access from this location

Code Implementation

Code Pack: Terraform
hth-microsoft-365-2.1-configure-named-locations.tf View source on GitHub ↗
# Named location: Trusted corporate IP ranges
resource "azuread_named_location" "corporate_network" {
  count = var.profile_level >= 2 && length(var.trusted_ip_ranges) > 0 ? 1 : 0

  display_name = "HTH: Corporate Network"

  ip {
    ip_ranges = var.trusted_ip_ranges
    trusted   = true
  }
}

# Named location: Blocked countries
resource "azuread_named_location" "blocked_countries" {
  count = var.profile_level >= 2 && length(var.blocked_country_codes) > 0 ? 1 : 0

  display_name = "HTH: Blocked Countries"

  country {
    countries_and_regions                 = var.blocked_country_codes
    include_unknown_countries_and_regions = true
  }
}

# Conditional Access policy: Block sign-ins from high-risk countries
resource "azuread_conditional_access_policy" "block_countries" {
  count = var.profile_level >= 2 && length(var.blocked_country_codes) > 0 ? 1 : 0

  display_name = "HTH: Block sign-ins from restricted countries"
  state        = "enabled"

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    locations {
      included_locations = [azuread_named_location.blocked_countries[0].id]
    }

    client_app_types = ["all"]
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}

# L3: Require MFA from non-trusted locations (reduce MFA fatigue for trusted)
resource "azuread_conditional_access_policy" "mfa_untrusted_locations" {
  count = var.profile_level >= 3 && length(var.trusted_ip_ranges) > 0 ? 1 : 0

  display_name = "HTH: Require MFA from non-trusted locations"
  state        = "enabled"

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    locations {
      included_locations = ["All"]
      excluded_locations = [azuread_named_location.corporate_network[0].id]
    }

    client_app_types = ["all"]
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}
Code Pack: CLI Script
hth-microsoft-365-2.1-configure-named-locations.ps1 View source on GitHub ↗
# Create named location via Graph API
$params = @{
    "@odata.type" = "#microsoft.graph.ipNamedLocation"
    displayName = "Corporate Network"
    isTrusted = $true
    ipRanges = @(
        @{
            "@odata.type" = "#microsoft.graph.iPv4CidrRange"
            cidrAddress = "203.0.113.0/24"
        }
    )
}

New-MgIdentityConditionalAccessNamedLocation -BodyParameter $params

3. OAuth & Integration Security

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 2.5
NIST 800-53 AC-3, CM-7
CIS M365 Benchmark 2.1

Description

Prevent users from granting OAuth consent to third-party applications. Require admin approval for all application access requests.

Rationale

Why This Matters:

  • OAuth consent phishing is a primary attack vector
  • Malicious apps can gain persistent access to mailboxes and data
  • Admin review ensures only vetted applications are authorized

Attack Prevented: OAuth consent phishing, malicious app installation, data exfiltration

Real-World Incidents:

  • Midnight Blizzard: Leveraged OAuth applications to gain elevated access and create malicious apps with full mailbox access

Prerequisites

  • Application Administrator or Global Administrator
  • Defined application approval workflow

ClickOps Implementation

Step 1: Disable User Consent

  1. Navigate to: Microsoft Entra admin centerApplicationsEnterprise applicationsConsent and permissions
  2. Under User consent settings, select Do not allow user consent
  3. Click Save

Step 2: Configure Admin Consent Workflow

  1. Navigate to: Admin consent settings
  2. Enable Users can request admin consent to apps they are unable to consent to
  3. Configure reviewers (Security team)
  4. Set notification email
  5. Click Save

Time to Complete: ~15 minutes

Code Implementation

Code Pack: CLI Script
hth-microsoft-365-3.1-restrict-user-consent.ps1 View source on GitHub ↗
# Disable user consent via Graph API
$params = @{
    defaultUserRolePermissions = @{
        permissionGrantPoliciesAssigned = @()
    }
}

Update-MgPolicyAuthorizationPolicy -BodyParameter $params

Validation & Testing

  1. Attempt to authorize a third-party app as standard user - should be blocked
  2. Submit admin consent request - verify workflow triggers
  3. Review existing app permissions: Enterprise applicationsAll applications → Review permissions

Expected result: Users cannot grant app permissions; admin approval required

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access security
NIST 800-53 AC-3 Access enforcement
CIS M365 2.1 Ensure third-party integrated applications are not allowed

3.2 Review and Revoke Overprivileged App Permissions

Profile Level: L2 (Hardened)

Framework Control
CIS Controls 2.6
NIST 800-53 AC-6

Description

Regularly audit enterprise applications for excessive permissions (especially Mail.Read, Mail.ReadWrite, full_access_as_app) and revoke unnecessary grants.

Rationale

Why This Matters:

  • Legacy OAuth apps accumulate permissions over time
  • full_access_as_app grants complete mailbox access
  • Compromised apps with excessive permissions enable data exfiltration

ClickOps Implementation

Step 1: Audit Application Permissions

  1. Navigate to: Microsoft Entra admin centerApplicationsApp registrationsAll applications
  2. For each app, review API permissions
  3. Flag apps with sensitive permissions:
    • Mail.ReadWrite (read/write all mail)
    • Files.ReadWrite.All (access all files)
    • Directory.ReadWrite.All (modify directory)
    • full_access_as_app (complete mailbox access)

Step 2: Revoke Unnecessary Permissions

  1. Select application → API permissions
  2. Click permission to remove → Remove permission
  3. Or delete unused applications entirely

Time to Complete: ~2-4 hours (initial audit)

Code Implementation

Code Pack: CLI Script
hth-microsoft-365-3.2-review-app-permissions.ps1 View source on GitHub ↗
# List all applications with Mail.ReadWrite permission
$apps = Get-MgApplication -All

foreach ($app in $apps) {
    $permissions = Get-MgApplication -ApplicationId $app.Id -Property RequiredResourceAccess
    $mailPermissions = $permissions.RequiredResourceAccess.ResourceAccess |
        Where-Object { $_.Id -eq "e2a3a72e-5f79-4c64-b1b1-878b674786c9" } # Mail.ReadWrite GUID

    if ($mailPermissions) {
        Write-Host "App: $($app.DisplayName) has Mail.ReadWrite permission"
    }
}

4. Data Security

4.1 Enable Sensitivity Labels and Data Loss Prevention

Profile Level: L2 (Hardened)

Framework Control
CIS Controls 3.1, 3.2
NIST 800-53 SC-8, SC-28

Description

Implement Microsoft Purview sensitivity labels to classify and protect sensitive data, and configure DLP policies to prevent unauthorized data sharing.

Rationale

Why This Matters:

  • Prevents accidental sharing of sensitive documents externally
  • Enables encryption that travels with the document
  • Provides visibility into data classification across the organization

ClickOps Implementation

Step 1: Create Sensitivity Labels

  1. Navigate to: Microsoft Purview compliance portalInformation protectionLabels
  2. Click + Create a label
  3. Configure label (e.g., “Confidential”):
    • Apply content marking (header/footer/watermark)
    • Apply encryption (restrict access to specific groups)
    • Apply auto-labeling conditions
  4. Publish labels to users

Step 2: Create DLP Policy

  1. Navigate to: Data loss preventionPolicies
  2. Click + Create policy
  3. Select template (e.g., “U.S. Financial Data”)
  4. Configure locations (Exchange, SharePoint, OneDrive, Teams)
  5. Set policy actions (block sharing, notify user, alert admin)
  6. Enable policy

Code Implementation


4.2 Configure External Sharing Restrictions

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 3.3
NIST 800-53 AC-3, AC-22
CIS M365 Benchmark 3.2

Description

Restrict external sharing in SharePoint and OneDrive to prevent unauthorized data exposure.

ClickOps Implementation

Step 1: Configure SharePoint Sharing

  1. Navigate to: SharePoint admin centerPoliciesSharing
  2. Set external sharing level:
    • Most restrictive: Only people in your organization
    • Recommended: Existing guests (requires authentication)
  3. Enable Guests must sign in using the same account to which sharing invitations are sent
  4. Set Allow sharing only with users in specific security groups if needed

Step 2: Configure OneDrive Sharing

  1. In same Sharing page, configure OneDrive settings
  2. Match or exceed SharePoint restrictions

Code Implementation

Code Pack: Terraform
hth-microsoft-365-4.2-external-sharing-restrictions.tf View source on GitHub ↗
# NOTE: SharePoint Online sharing settings are managed through the Microsoft 365
# admin APIs, not the azuread provider. Use the SPO PowerShell module or
# Microsoft Graph API for direct configuration:
#
#   Set-SPOTenant -SharingCapability ExistingExternalUserSharingOnly
#   Set-SPOTenant -RequireAcceptingAccountMatchInvitedAccount $true
#   Set-SPOTenant -PreventExternalUsersFromResharing $true
#
# This file creates the Azure AD groups needed for conditional sharing policies.

# Group for users authorized to share externally
resource "azuread_group" "external_sharing_authorized" {
  display_name     = "HTH: External Sharing Authorized"
  description      = "Users permitted to share documents with external parties"
  security_enabled = true
  mail_enabled     = false
}

# Conditional Access: Restrict unmanaged device access to web-only
# Prevents downloading/syncing sensitive data on personal devices
resource "azuread_conditional_access_policy" "restrict_unmanaged_devices" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name = "HTH: Restrict unmanaged device access"
  state        = "enabled"

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      # SharePoint Online and OneDrive application IDs
      included_applications = [
        "00000003-0000-0ff1-ce00-000000000000", # SharePoint Online
      ]
    }

    client_app_types = ["browser"]
  }

  session_controls {
    application_enforced_restrictions_enabled = true
  }
}

# L3: Block external sharing entirely except to allowed domains
resource "azuread_group" "allowed_external_domains" {
  count = var.profile_level >= 3 && length(var.allowed_external_domains) > 0 ? 1 : 0

  display_name     = "HTH: Allowed External Domains"
  description      = "Reference group for external domain allow-list (domains: ${join(", ", var.allowed_external_domains)})"
  security_enabled = true
  mail_enabled     = false
}
Code Pack: CLI Script
hth-microsoft-365-4.2-external-sharing-restrictions.ps1 View source on GitHub ↗
# Connect to SharePoint Online
Connect-SPOService -Url "https://yourdomain-admin.sharepoint.com"

# Set tenant-level sharing restrictions
Set-SPOTenant -SharingCapability ExistingExternalUserSharingOnly
Set-SPOTenant -RequireAcceptingAccountMatchInvitedAccount $true
Set-SPOTenant -PreventExternalUsersFromResharing $true

5. Monitoring & Detection

5.1 Enable Unified Audit Logging

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 8.2
NIST 800-53 AU-2, AU-3, AU-6
CIS M365 Benchmark 5.1

Description

Enable and configure unified audit logging to capture user and admin activities across all Microsoft 365 services.

Rationale

Why This Matters:

  • Audit logs are essential for incident investigation
  • Provides visibility into data access, sharing, and admin changes
  • Required for compliance with most security frameworks
  • Default retention is 180 days (E5) or 90 days (other plans)

ClickOps Implementation

Step 1: Verify Audit Logging is Enabled

  1. Navigate to: Microsoft Purview compliance portalAudit
  2. If prompted, click Start recording user and admin activity
  3. Verify audit search returns results

Step 2: Configure Audit Log Retention (E5)

  1. Navigate to: AuditAudit retention policies
  2. Create policy for extended retention (up to 10 years for E5)
  3. Apply to high-value activities (MailItemsAccessed, SharePoint file access)

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-microsoft-365-5.1-enable-unified-audit-logging.tf View source on GitHub ↗
# NOTE: Unified audit logging is managed through Exchange Online PowerShell,
# not the azuread provider. Enable via:
#
#   Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true
#   Get-Mailbox -ResultSize Unlimited | Set-Mailbox -AuditEnabled $true
#
# This file creates the Azure AD groups and Conditional Access signals needed
# to support audit logging infrastructure and monitoring.

# Security group for audit log reviewers (SIEM integration service accounts)
resource "azuread_group" "audit_log_reviewers" {
  display_name     = "HTH: Audit Log Reviewers"
  description      = "Security team members and service accounts with audit log access"
  security_enabled = true
  mail_enabled     = false
}

# Group for mailbox auditing scope (users requiring enhanced auditing)
resource "azuread_group" "enhanced_audit_scope" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name     = "HTH: Enhanced Audit Scope"
  description      = "Users with enhanced mailbox auditing (MailItemsAccessed, Send)"
  security_enabled = true
  mail_enabled     = false
}

# L2+: Application registration for SIEM integration
resource "azuread_application" "siem_integration" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name = "HTH: SIEM Audit Log Integration"

  required_resource_access {
    # Microsoft Graph
    resource_app_id = "00000003-0000-0000-c000-000000000000"

    resource_access {
      # AuditLog.Read.All (Application)
      id   = "b0afded3-3588-46d8-8b3d-9842eff778da"
      type = "Role"
    }

    resource_access {
      # Directory.Read.All (Application)
      id   = "7ab1d382-f21e-4acd-a863-ba3e13f7da61"
      type = "Role"
    }
  }

  web {
    redirect_uris = []
  }
}

# Service principal for the SIEM integration app
resource "azuread_service_principal" "siem_integration" {
  count = var.profile_level >= 2 ? 1 : 0

  client_id = azuread_application.siem_integration[0].client_id
}
Code Pack: CLI Script
hth-microsoft-365-5.1-enable-unified-audit-logging.ps1 View source on GitHub ↗
# Connect to Exchange Online
Connect-ExchangeOnline

# Verify audit logging is enabled
Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled

# Enable if not already enabled
Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true

# Enable mailbox auditing for all mailboxes
Get-Mailbox -ResultSize Unlimited | Set-Mailbox -AuditEnabled $true

Key Events to Monitor

Event Description Detection Use Case
MailItemsAccessed Email accessed via sync or client Compromised account data access
New-InboxRule Inbox rule created Attacker persistence/hiding
Add-MailboxPermission Mailbox delegation added Lateral movement
Set-ConditionalAccessPolicy CA policy modified Security control bypass
Add application App registration created Malicious app installation

5.2 Configure Security Alerts and Microsoft Defender

Profile Level: L1 (Baseline)

Framework Control
CIS Controls 8.11
NIST 800-53 SI-4

Description

Enable Microsoft Defender for Office 365 and configure alert policies for suspicious activities.

ClickOps Implementation

Step 1: Review Default Alert Policies

  1. Navigate to: Microsoft Defender portalEmail & collaborationPolicies & rulesAlert policy
  2. Review and enable critical alerts:
    • Suspicious email sending patterns
    • Malware campaign detected
    • User reported phishing
    • Unusual external file sharing

Step 2: Configure Custom Alerts

  1. Click + New alert policy
  2. Create alerts for:
    • Global Admin role assignment
    • Conditional Access policy changes
    • New OAuth app with sensitive permissions

Code Implementation

Code Pack: Terraform
hth-microsoft-365-5.2-configure-security-alerts.tf View source on GitHub ↗
# NOTE: Microsoft Defender for Office 365 alert policies are managed through
# the Security & Compliance PowerShell module, not the azuread provider.
#
# This file provisions the Azure AD group structure needed for alert routing
# and the Conditional Access policy for risky sign-in detection.

# Security operations group for alert notification routing
resource "azuread_group" "security_operations" {
  display_name     = "HTH: Security Operations"
  description      = "Security team members receiving Defender and audit alert notifications"
  security_enabled = true
  mail_enabled     = false
}

# L1: Conditional Access policy responding to sign-in risk
# Requires Entra ID P2 for risk-based Conditional Access
resource "azuread_conditional_access_policy" "risky_signin_mfa" {
  display_name = "HTH: Require MFA for risky sign-ins"
  state        = "enabled"

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    sign_in_risk_levels = ["medium", "high"]
    client_app_types    = ["all"]
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

# L2+: Block high-risk sign-ins entirely
resource "azuread_conditional_access_policy" "block_high_risk_signin" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name = "HTH: Block high-risk sign-ins"
  state        = "enabled"

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    sign_in_risk_levels = ["high"]
    client_app_types    = ["all"]
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}

# L2+: Respond to user risk -- require password change for risky users
resource "azuread_conditional_access_policy" "risky_user_remediation" {
  count = var.profile_level >= 2 ? 1 : 0

  display_name = "HTH: Require password change for risky users"
  state        = "enabled"

  conditions {
    users {
      included_users = ["All"]
      excluded_users = [for u in data.azuread_user.break_glass : u.object_id]
    }

    applications {
      included_applications = ["All"]
    }

    user_risk_levels = ["high"]
    client_app_types = ["all"]
  }

  grant_controls {
    operator          = "AND"
    built_in_controls = ["mfa", "passwordChange"]
  }
}

6. Third-Party Integration Security

6.1 Integration Risk Assessment Matrix

Risk Factor Low Medium High
Data Access Read-only, limited scope Read most data Write access, full mailbox
OAuth Scopes Specific scopes Broad API access Full admin/app-only
Session Duration <2 hours 2-8 hours Persistent
Vendor Security SOC 2 Type II + ISO SOC 2 Type I No certification

Obsidian Security

Data Access: Read (email metadata, audit logs, directory) Recommended Controls:

  • ✅ Use dedicated service account
  • ✅ Grant minimum required Graph API permissions
  • ✅ Enable audit logging for Obsidian’s service principal
  • ✅ Review permissions quarterly

Slack

Data Access: Medium (channel sync, user directory) Recommended Controls:

  • ✅ Limit to specific channels for Teams-Slack integration
  • ✅ Disable file sync if not required
  • ✅ Monitor for data exfiltration patterns

7. Compliance Quick Reference

SOC 2 Trust Services Criteria Mapping

Control ID M365 Control Guide Section
CC6.1 MFA for all users 1.1
CC6.1 Block legacy auth 1.2
CC6.2 Privileged Identity Management 1.3
CC6.6 External sharing restrictions 4.2
CC7.2 Unified audit logging 5.1

NIST 800-53 Rev 5 Mapping

Control M365 Control Guide Section
IA-2(1) MFA to privileged accounts 1.1
IA-2(6) Phishing-resistant MFA 1.1
AC-2(7) Privileged user accounts 1.3
AC-3 Access enforcement 3.1
AU-2 Audit events 5.1

CIS Microsoft 365 Foundations Benchmark v3.1 Mapping

Recommendation M365 Control Guide Section
1.1.1 Ensure MFA is enabled for all users 1.1
1.1.2 Block legacy authentication 1.2
1.1.4 Enable Conditional Access policies 1.1
2.1 Block third-party app consent 3.1
5.1 Enable unified audit logging 5.1

Appendix A: Edition/Tier Compatibility

Control Microsoft 365 Business Basic Business Premium E3 E5 Add-on Required
Security Defaults MFA No
Conditional Access Entra ID P1
Privileged Identity Management Entra ID P2
Sensitivity Labels (basic) No
Auto-labeling No
Advanced Audit No
Defender for Office 365 P2 Add-on for E3

Appendix B: References

Official Microsoft Documentation:

API Documentation:

Compliance Frameworks:

Hardening Benchmarks:

Security Incidents:


Changelog

Date Version Maturity Changes Author
2025-02-05 0.1.0 draft Initial guide with authentication, OAuth, data security, and monitoring controls Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?