Azure AD app registration for Pentestas Azure / M365 scanning
Pentestas scans your Microsoft 365 and Azure estate by impersonating a single multi-tenant application that you register once in your own home tenant. Every customer tenant grants that same application admin consent, which provisions a service principal in their directory. Pentestas then authenticates tenant-by-tenant using the same client ID but switching the token audience.
This guide walks through the exact registration, permissions, authentication, and customer onboarding steps. It assumes you are the operator running Pentestas as a service — if you are an end customer trying to grant access to someone else's Pentestas deployment, skip to § 7 Customer admin consent flow.
1. Architecture — one app, many tenants
┌────────────────────────────┐ ┌────────────────────────────┐
│ Pentestas home tenant │ │ Customer tenant A │
│ (you own this) │ │ │
│ │ │ SP provisioned on │
│ App registration │───────▶│ admin consent │
│ • Client ID (public) │ │ │
│ • Client secret OR cert │ │ Your app shows up in: │
│ • Multi-tenant = Yes │ │ Enterprise applications │
│ │ │ │
└────────────┬───────────────┘ └────────────────────────────┘
│ ┌────────────────────────────┐
│ │ Customer tenant B │
└────────────────────────▶│ Same SP ID, different │
│ tenant, separate consent │
└────────────────────────────┘
You register the app exactly once. You do NOT create one app per customer. Per-customer registration does not scale, forces you to maintain dozens of secret rotations, and gives customers no way to see (or revoke) their access from their Enterprise applications panel.
The app is public to the OAuth directory discovery endpoint but has no tokens in any tenant until that tenant's Global Admin explicitly grants consent. Revoking your access is a one-click "Remove" on the service principal in Entra ID → Enterprise applications.
2. Choose the authentication method up front
| Method | When to use | Security |
|---|---|---|
| Certificate (X.509) | Production. Required for Exchange Online app-only auth (-CertificateThumbprint). |
Best — private key never transmitted. |
| Client secret | Development and onboarding dry-runs. Acceptable if rotated ≤90 days. | Adequate for graph-only scopes. |
| Federated credential (workload identity) | Pentestas workers running inside Azure (AKS / App Service). | Best — no long-lived secret. |
Pick one per environment. Pentestas supports all three simultaneously (one in the home tenant for graph + another for Exchange Online is common because Exchange insists on a certificate).
Generate a self-signed cert for Exchange Online:
# Run on any Windows or Linux host with pwsh 7
$cert = New-SelfSignedCertificate `
-Subject "CN=pentestas-exchange-app-only" `
-KeyAlgorithm RSA -KeyLength 2048 `
-NotAfter (Get-Date).AddYears(2) `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy Exportable
Export-Certificate -Cert $cert -FilePath pentestas.cer # public key → upload to Entra
Export-PfxCertificate -Cert $cert -FilePath pentestas.pfx ` # private key → keep in your secret store
-Password (ConvertTo-SecureString -String "<strong-passphrase>" -Force -AsPlainText)
Store pentestas.pfx in your secret manager (AWS Secrets Manager, HashiCorp
Vault, Azure Key Vault — NOT in the repository, NOT in environment variables
pasted into a .env file).
3. Register the application (Pentestas home tenant)
- Sign in to the Azure portal as a Global Administrator of your home tenant (not a customer tenant).
- Entra ID → App registrations → New registration.
- Name →
Pentestas Security Scanner. This is the name customers see on the consent dialog, so keep it honest and branded. - Supported account types →
Accounts in any organizational directory (Any Microsoft Entra ID tenant — Multitenant). - Redirect URI →
Web→https://app.pentestas.com/api/azure/consent-callback(adjust host to your deployment). This is the URL customers land on after granting consent. - Click Register. Copy the Application (client) ID and the Directory (tenant) ID — you will reference these in Pentestas settings.
4. Upload the certificate (or create a secret)
Certificate path (recommended)
- In the registration blade, Certificates & secrets → Certificates → Upload certificate.
- Upload
pentestas.cer(public key only, never the.pfx). - Note the Thumbprint — Pentestas needs it for
Connect-ExchangeOnline -CertificateThumbprint.
Client secret path
- Certificates & secrets → Client secrets → New client secret.
- Description:
pentestas-primary. Expiry: 90 days (180 max). - Copy the Value immediately — it is only shown once. Store it in your secret manager alongside the certificate PFX.
Set a calendar alert 7 days before expiry. A silently-expired secret will take down every customer scan at once.
5. Grant API permissions
Pentestas uses four API surfaces. Add them in this order.
Inside the registration blade → API permissions → Add a permission.
5.1 Microsoft Graph (application permissions)
These unlock Entra ID, Security, Intune, Policy, and most of SharePoint via Graph:
| Permission | Reason |
|---|---|
AuditLog.Read.All |
Read unified audit log (CIS 3.1.1). |
Directory.Read.All |
Enumerate users, groups, roles, service principals. |
Policy.Read.All |
Read Conditional Access, Authorization, Authentication Methods policies. |
Policy.Read.ConditionalAccess |
Detailed CA policy evaluation. |
PrivilegedAccess.Read.AzureAD |
Read PIM eligible/active role assignments. |
RoleManagement.Read.All |
Directory role + role-template enumeration. |
SecurityEvents.Read.All |
Read Secure Score, security alerts. |
IdentityRiskEvent.Read.All |
Read identity protection risk detections. |
IdentityRiskyUser.Read.All |
Read risky users list. |
UserAuthenticationMethod.Read.All |
Per-user MFA registration state. |
Organization.Read.All |
Read tenant config (dirsync state, licences). |
Application.Read.All |
Enumerate applications + service principal credentials. |
Group.Read.All |
Read groups, memberships, dynamic rules. |
Domain.Read.All |
Read verified domains (DMARC / SPF correlations). |
Reports.Read.All |
Read usage/activity reports. |
ThreatAssessment.Read.All |
Read Defender threat assessment results. |
DeviceManagementConfiguration.Read.All |
Intune configuration profile audit. |
DeviceManagementManagedDevices.Read.All |
Managed device posture. |
5.2 Office 365 Exchange Online (application permissions)
Office 365 Exchange Online must be added explicitly — it's not under Graph:
| Permission | Reason |
|---|---|
Exchange.ManageAsApp |
Mandatory. Enables Connect-ExchangeOnline -AppId -CertificateThumbprint. Without this the entire Exchange module runs with zero output. |
In addition, the app principal must be assigned the Exchange Global Reader
directory role (or Exchange Recipient Administrator for read-mostly).
This is done under Roles and administrators, NOT API permissions. Without
this role the app can authenticate but returns Access is denied on every
cmdlet.
5.3 SharePoint (application permissions)
| Permission | Reason |
|---|---|
Sites.FullControl.All |
PnP.PowerShell needs it for Get-PnPTenantSite, sharing policies, DLP evaluations. Sites.Read.All is insufficient for tenant-level settings. |
User.Read.All |
Resolving site owners / sharing recipients. |
5.4 Azure Service Management (delegated)
| Permission | Reason |
|---|---|
user_impersonation |
Needed for Connect-AzAccount in the delegated flow used by a handful of Defender for Cloud and Policy cmdlets that still don't accept app-only tokens. |
Optional surfaces if the customer has E5 / specific licences — add only if you sell those scans:
| API | Permission |
|---|---|
| Microsoft Threat Protection | IncidentAlerts.Read.All, ThreatHunting.Read.All |
| Microsoft Purview | InformationProtectionPolicy.Read.All |
| Microsoft Defender ATP | Alert.Read.All, Machine.Read.All |
6. Grant admin consent for your home tenant
After all permissions are added, click Grant admin consent for
All permissions should turn green (status "Granted for
7. Customer admin consent flow
Each customer tenant goes through this once. Pentestas ships a consent URL builder; the URL template is:
https://login.microsoftonline.com/{CUSTOMER_TENANT_ID_OR_DOMAIN}/adminconsent
?client_id={PENTESTAS_APP_ID}
&redirect_uri={PENTESTAS_REDIRECT_URI}
&state={PENTESTAS_NONCE}
Example:
https://login.microsoftonline.com/contoso.onmicrosoft.com/adminconsent
?client_id=00000000-0000-0000-0000-000000000000
&redirect_uri=https%3A%2F%2Fapp.pentestas.com%2Fapi%2Fazure%2Fconsent-callback
&state=a3f7e2c1
The customer Global Admin:
- Opens the URL.
- Signs in if not already signed in.
- Reviews the "Permissions requested" dialog — this lists every application
permission from § 5. You are responsible for making this list honest.
If you asked for
Directory.ReadWrite.Allwhen you only need Read, the customer sees that and will (rightly) refuse consent. - Clicks Accept.
- Lands on
{PENTESTAS_REDIRECT_URI}with?admin_consent=True&tenant={tid}.
After consent, the app principal (service principal) exists in the customer's tenant. You can verify in:
Entra ID → Enterprise applications → filter by Application ID.
7.1 Exchange Online post-consent step (customer side)
Because Exchange.ManageAsApp needs a directory role, the customer admin
must also run, once, in a PowerShell session connected to their tenant:
# Connect interactively first (customer admin)
Connect-MgGraph -Scopes "RoleManagement.ReadWrite.Directory" -TenantId <customer-tenant>
$sp = Get-MgServicePrincipal -Filter "appId eq '{PENTESTAS_APP_ID}'"
$roleId = (Get-MgRoleManagementDirectoryRoleDefinition `
-Filter "displayName eq 'Global Reader'").Id
New-MgRoleManagementDirectoryRoleAssignment `
-PrincipalId $sp.Id `
-RoleDefinitionId $roleId `
-DirectoryScopeId "/"
Pentestas ships this as a one-click assistant — the customer doesn't see raw cmdlets — but the script above is what it runs on their behalf via a delegated-flow prompt.
7.2 Teams post-consent step
A handful of Teams cmdlets (Get-CsTenantFederationConfiguration,
Get-CsTeamsMeetingPolicy in some tenants) still reject app-only tokens.
For these, Pentestas falls back to device-code OAuth with the customer's
admin. That is one-time per scan; the admin pastes a code and approves. If
the customer declines device-code, the Teams check set falls back to the
CIS subset reachable via Graph Beta (reduced coverage).
8. Where Pentestas stores the credentials
Inside Pentestas:
| Setting | Storage |
|---|---|
| App client ID | tenant.azure_app_client_id (plain, not secret) |
| App tenant ID (home) | tenant.azure_app_tenant_id (plain) |
| Client secret | tenant.azure_app_client_secret_enc (Fernet-encrypted at rest with per-tenant DEK) |
| Certificate PFX | Uploaded via Pentestas settings → stored encrypted blob, loaded into the PowerShell sidecar's ephemeral cert store at scan start |
| Certificate thumbprint | tenant.azure_app_cert_thumbprint (plain) |
| Customer consent state | azure_tenant_consent table (one row per customer tenant with consent timestamp, admin upn, granted scopes) |
Credentials are never returned by the Pentestas API after initial save. The
UI shows a breadcrumb ••••••{last 4} and a "Rotate" button.
9. Verifying the setup end-to-end
From a workstation with pwsh 7 and the Microsoft modules (Az,
ExchangeOnlineManagement, PnP.PowerShell, Microsoft.Graph,
Microsoft.Graph.Beta, MicrosoftTeams) installed:
$ClientId = "<your app id>"
$TenantId = "<customer tenant id>"
$Thumbprint = "<cert thumbprint>"
$Organization = "<customer>.onmicrosoft.com"
# Graph — should return tenant displayName
Connect-MgGraph -ClientId $ClientId -TenantId $TenantId -CertificateThumbprint $Thumbprint
Get-MgOrganization | Select-Object DisplayName, VerifiedDomains
Disconnect-MgGraph
# Exchange Online — should return mailbox count
Connect-ExchangeOnline -AppId $ClientId -CertificateThumbprint $Thumbprint -Organization $Organization -ShowBanner:$false
(Get-Mailbox -ResultSize Unlimited).Count
Disconnect-ExchangeOnline -Confirm:$false
# SharePoint (PnP) — should return tenant URL and sharing mode
Connect-PnPOnline -Url "https://<customer>-admin.sharepoint.com" `
-ClientId $ClientId -Tenant $TenantId -Thumbprint $Thumbprint
Get-PnPTenant | Select-Object SharingCapability, DefaultSharingLinkType
Disconnect-PnPOnline
# Teams — expected to prompt device-code unless fully app-only
Connect-MicrosoftTeams -ApplicationId $ClientId -TenantId $TenantId -CertificateThumbprint $Thumbprint
Get-CsTenantFederationConfiguration
Disconnect-MicrosoftTeams
All four should succeed. If any errors, jump to § 11 troubleshooting.
10. Customer offboarding (revoke access)
Two parallel paths exist. Either works — use whichever the customer prefers.
Customer-initiated revoke
Customer Global Admin → Entra ID → Enterprise applications →
Pentestas Security Scanner → Properties → Delete.
All tokens are invalidated immediately. Pentestas scans against that tenant
start returning AADSTS700016 (app not found in tenant) on the next run —
Pentestas catches this and marks the tenant's integration as revoked.
Pentestas-initiated revoke
On the Pentestas UI, Settings → Azure integration → Disconnect tenant. This does NOT delete the SP in the customer tenant (Pentestas cannot — it's the customer's directory) but it:
- Deletes the stored client secret / cert reference for that tenant.
- Inserts a revocation log entry with actor + timestamp.
- Stops scheduling scans against that tenant.
The customer is emailed with the cleanup step they can run on their side.
11. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
AADSTS65001: The user or administrator has not consented |
Consent URL clicked but admin declined, or the admin wasn't a Global Admin (Application Admin is insufficient for some scopes). | Re-send the consent link to a Global Admin. Confirm the admin role with Get-MgUser -Filter "userPrincipalName eq '…'" -ExpandProperty MemberOf. |
AADSTS700016: Application with identifier '…' was not found in the directory |
Customer has never consented, or they deleted the SP. | Run the consent URL. If the SP was deleted, repeat admin consent; a new SP with the same app ID will be provisioned. |
Exchange cmdlets return empty or Access denied |
Directory role not assigned to the SP. | Re-run § 7.1 — the Exchange.ManageAsApp permission alone is not enough. The Global Reader role binding is mandatory. |
Connect-ExchangeOnline succeeds but every Get-* times out |
Organization parameter wrong. | Must be the *.onmicrosoft.com tenant domain, not the vanity domain, and not a GUID. |
SharePoint cmdlets: 403 Forbidden |
Missing Sites.FullControl.All OR PnP app-consent policy blocking legacy SharePoint app model. |
Use the app-only setup via Microsoft.Graph (recommended) OR register an SP Add-In — see PnP docs. |
| Teams cmdlets hang forever | App-only not supported for that cmdlet in that tenant. | Fall back to device-code delegated auth. Pentestas does this automatically on the first timeout. |
Graph call returns insufficient privileges on one specific scope |
Permission was added but admin consent was never re-granted. | In the home tenant registration → API permissions → Grant admin consent again. Ditto in each customer tenant (use the consent URL with the &prompt=consent query param appended to force re-consent dialog). |
12. Reference — minimum permission set by module
If you need to propose a reduced-permission variant to a security-conscious customer (e.g. "we only want you to scan Entra ID, not Exchange"), this table lets you tell them exactly which permissions to decline.
| Pentestas scan module | Graph | Exchange | SharePoint | Teams | Azure SM |
|---|---|---|---|---|---|
| Entra ID (identity, CA, PIM, risk) | §5.1 all | — | — | — | — |
| Exchange Online | AuditLog.Read.All |
Exchange.ManageAsApp + Global Reader role |
— | — | — |
| SharePoint / OneDrive | Sites.Read.All |
— | Sites.FullControl.All, User.Read.All |
— | — |
| Teams | Directory.Read.All |
— | — | (device-code fallback) | — |
| Defender for Cloud | — | — | — | — | user_impersonation |
| Intune / device posture | DeviceManagement*.Read.All |
— | — | — | — |
Pentestas' scan config page reflects this — if a customer declines Exchange
permissions, the Exchange checks show as Skipped: missing consent in the
report rather than silently zero-finding.
Last updated: 2026-04-22.
If you change the permission set above, update this doc and the consent-URL
builder in backend/app/api/azure.py in the same PR. The doc is the contract
with your customers.