Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration PART 2

Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration PART 2

Unlock full CRUD access to Microsoft Forms through PowerShell and Azure App Registrations

In Part 1 we reverse-engineered the read side of Microsoft Forms’ hidden API – enough to pull forms and responses straight into PowerShell without ever opening a browser.

No premium connectors, no extra licensing – just an App Registration treating Forms as if it were an official Microsoft Graph API.

In this part, we’ll unlock the mutation side of the same API:

  • Creating new forms
  • Adding questions and sections to them
  • Updating titles, descriptions and settings
  • Deleting forms when you’re done

One change since Part 1: Microsoft has been moving the Forms API off forms.office.com and onto forms.cloud.microsoft as part of a broader push to consolidate Microsoft 365 services onto dedicated, more tightly-scoped domains for better security.

Same disclaimer as Part 1 applies: none of this is documented by Microsoft. It’s reconstructed from watching what the Forms web app actually sends over the wire, so treat it as unofficial and test carefully before relying on it for anything important.

Updating our App Registration in Azure

In Part 1, we set up an Azure App Registration with Forms.Read.All and Forms.Read permissions – enough to read data from Microsoft Forms.

To create, update, or delete forms, we need to grant our app write access instead. That means swapping the read-only scopes for:

  • Forms.ReadWrite.All – application permission
  • Forms.ReadWrite – delegated permission
  1. Go to Azure Portal → App Registrations
  2. Select the app you created in Part 1 (or create a new one if you’d rather keep read and write access separate)
  3. Under API Permissions → Add a permission → APIs my organization uses, search for Microsoft Forms
  4. Select Application permissions → Forms.ReadWrite.All (for group-owned forms, use Delegated permissions → Forms.ReadWrite instead)
  5. Grant admin consent

That upgrades the app from read-only to full CRUD access on Forms – letting you create, edit and delete forms just like the Forms web app does internally.

Updating the Token Manager Class to support Creating Forms and Certificate Authentication

The TokenManager class from Part 1 supported client-secret and ROPC token flows. Support has been added for certificate-based authentication too.

  • ROPC (Resource Owner Password Credentials) – authenticating as a specific user with delegated permissions, useful when you want forms created “as” a real person rather than an app
  • Certificate-based authentication – a more secure alternative to client secrets, using a JWT client assertion signed with a certificate’s private key instead of a shared secret
  • A resource parameter – some of the older v1 token endpoints (still used by parts of the Forms create flow) expect resource rather than the v2 scope parameter

Two implementation details are worth calling out, since they explain a couple of choices in the class below:

  • The six auth combinations are built as named static factory methods – [TokenManager]::FromClientSecret(...), ::FromCertificate(...), ::FromRopcClientSecret(...) and so on – rather than overloaded constructors. PowerShell resolves constructor overloads by parameter type, and two of the six combinations (ROPC with a secret, and client-credentials with a certificate) both need exactly six string parameters in a row – an identical signature PowerShell won’t allow as two separate constructors. Named factories sidestep that collision entirely and make each call site self-documenting.
  • The client-assertion JWT header carries a PS256 algorithm and an x5t#S256 claim – the base64url-encoded SHA-256 thumbprint of the signing certificate – matching Microsoft’s current documented format for certificate credentials, rather than the older (still widely-used, but no longer the documented) RS256/SHA-1 x5t pairing.
  • The JWT’s aud claim matches whichever token endpoint this specific request is actually going to – the v1 URL for the WithResource factories, the v2.0 URL otherwise. Entra validates the assertion’s audience against the realm of the endpoint that receives the request, so a v1 request needs a v1 aud and a v2 request needs a v2 aud. Mismatching the two is exactly what produces AADSTS700023: Client assertion audience claim does not match Realm issuer – a genuinely easy mistake to make, since plenty of certificate-auth sample code only ever exercises the v2 path and hardcodes aud accordingly.

RefreshToken() sends scope and resource together whenever both are set – the v1 endpoint just ignores the extra scope parameter, so there’s no need to withhold it.

Here’s the class in full. It supports six combinations: client-credentials or ROPC, secret or certificate, with or without a v1 resource:

ROPC flow with username/password and client secret

Authenticates as a specific user, using a client secret to prove the app’s identity:

ROPC flow with username/password and certificate

Same delegated flow, but authenticating the app with a certificate instead of a secret:

Client credentials flow with client secret

The application-only flow we used in Part 1:

Client credentials flow with certificate

Application-only, authenticated with a certificate:

Client credentials flow with client secret and resource

Some Forms create endpoints still expect a v1-style resource token rather than a v2 scope token – this factory targets the v1 endpoint. The resource value has to be Microsoft Forms’ own Application ID URI – api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87 – not a bare domain. A plain https://forms.cloud.microsoft still gets a token issued without error, but that token’s audience doesn’t actually match what Forms expects, and the mismatch only surfaces later as a confusing failure on the API call itself rather than a clean rejection at token time:

Client credentials flow with certificate and resource

Same v1 resource flow, authenticated with a certificate:

Creating a New Form with PowerShell

With write scopes in place and a token manager that can handle whichever auth flow we need, we can create a form. Here’s New-MSForm:

Understanding the parameters

ResponseMode is the one that matters most – it maps onto the same “who can fill this in” choices you’d see in the Forms UI:

  • Anyone – a public, anonymous link. No sign-in required, no identity recorded.
  • OrgAnyone – anyone signed into your organisation’s tenant can respond.
  • OrgSpecific – only named individuals can respond, passed via SpecificResponders.

RequiresUniqueResponse restricts each respondent to a single submission, and NotRecordIdentity stops the form from recording who submitted a response even though they had to sign in to access it. Both only make sense for OrgAnyone or OrgSpecific forms – the function throws if you try to set either alongside Anyone, since an anonymous form has no identity to de-duplicate or record in the first place.

Example Usages

All three examples below assume a v1-resource token, since the create endpoint is one of the ones that still wants a resource-flavoured token:

Example 1: Public form

Anyone with the link can respond – no sign-in, no identity recorded:

Example 2: Anyone in the organisation

Anyone signed into the tenant can respond, and we’re limiting each person to one response:

Example 3: Specific people only

Only the named respondents can access the form:

Updating Form Details

From here on, every function needs the same four pieces of context – TenantId, OwnerId, OwnerType and FormId – to build the form’s endpoint. Rather than repeating that construction in every function, I’ve pulled it into one small helper that the rest of the library calls internally:

A form created with New-MSForm has a title but no description. Both are set with a PATCH straight to the form’s own resource URL – and interestingly, they’re captured as two separate requests rather than one combined update, so the functions below keep that split rather than merging them. I’m keeping the generic Invoke-HttpRequestWithToken helper from earlier in the post too – every function below is really just a thin, validated wrapper around it:

The duplicated title/formsProRTTitle and description/formsProRTDescription pairs aren’t a typo – the Forms web app writes both every time. Why isn’t clear from the captures alone; the “RT” naming hints at something rich-text-related, but that’s a guess rather than a confirmed reason, so both are set here to keep the two fields in sync regardless.

Adding Questions to Your Form

Every question type follows the same two-step pattern under the hood: POST to the form’s questions collection to create a question, then PATCH the specific question to reconfigure it later if needed. The functions below wrap both steps – an Add-MSForm*Question function that creates a fully-configured question in one call, and an Update-MSForm*Question function for editing it afterwards.

Three quirks the functions handle for you, worth knowing about anyway:

  • The question’s id is chosen by the client, not returned by the server – the Forms web app generates a token like r61e46485c47f41c0b62760fb1e2bdcc5 (an “r” prefix followed by a random hex string) and sends it in the create request. Each Add- function below generates one with "r" + [guid]::NewGuid().ToString("N").
  • The order field jumps in increments of roughly 1,000,000 per question (1000500, 2000500, 3000500…) rather than counting up by one. That’s a sparse ordering scheme – it leaves enormous gaps so questions can be reordered or inserted later without renumbering everything else. Each function below defaults Order to its own slot in that scheme, but takes it as a parameter so you can override it.
  • The body that configures a question is doubly-encoded: the outer request body is a normal object, but the questionInfo field inside it is itself a JSON string, not a nested object. Every function builds this with ConvertTo-Json -Compress on an inner hashtable.
Choice questions

ChoiceType is 1 for single-select and 2 for multiple-select, which is why the functions derive it from the -AllowMultipleAnswers switch rather than taking it directly. RestrictionType can be None, Exactly, or AtMost, paired with RestrictionValue to cap how many options can be picked. To reorder options, just call Update-MSFormChoiceQuestion again with -Choices in the order you want – unlike ranking questions (below), there’s no separate per-choice endpoint here.

Text Field questions

Text fields can also carry a validation rule. The rule codes aren’t documented anywhere, so here’s the reverse-engineered map Update-MSFormTextFieldQuestion is built around:

  • 0 – IsNumber – must be a number
  • 1 – Greater – greater than MinBoundary
  • 2 – GreaterOrEqual – greater than or equal to MinBoundary
  • 3 – Less – less than MaxBoundary
  • 4 – LessOrEqual – less than or equal to MaxBoundary
  • 5 – Equal – equal to MinBoundary (reuses the min-boundary field for the single comparison value)
  • 6 – NotEqual – not equal to MinBoundary
  • 7 – Between – between MinBoundary and MaxBoundary
  • 8 – NotBetween – outside MinBoundary and MaxBoundary
  • 9 – maximum text length, via MaxBoundary
  • 10 – minimum text length, via MinBoundary
  • 11 – must be a valid email address
  • 12 – Contains – text must contain TextInput
  • 13 – DoesntContain – text must not contain TextInput
  • 14 – must be a valid URL
  • 15 – WholeNumber – must be a whole number

Creating a short-answer question, then switching it to long-answer and restricting it to a whole number between 1 and 100:

Rating questions
Date questions
Ranking questions

Ranking questions behave differently from Choice questions – choices live under their own sub-resource, and reordering them means patching individual choices rather than resending the whole list, so they get their own dedicated functions:

Likert (matrix) questions

Likert questions are the odd one out structurally – a “group” question holds the response scale (the columns), and each row statement is actually its own separate question object linked back to the group via groupId. That structure carries straight through into the functions:

File Upload questions

File Upload follows the same generic-create-then-configure pattern as Date, NPS and the Likert group – questionInfo is empty on creation, and file count, size and type restrictions are set afterwards through Update-MSFormFileUploadQuestion.

FileTypes has to be a nested object listing all seven file types every time, not just the ones you’re restricting to, and three fields that have nothing to do with file uploads – ShuffleOptions, ShowRatingLabel, IsMathQuiz – get sent as constant boilerplate alongside it. Leave any of that out and the request fails with the same vague, unhelpful error rather than a clear validation message.

MaxFileSizeMB maps straight onto the API’s MaxFileSize, which is in MB – so 1GB is -MaxFileSizeMB 1000:

NPS (Net Promoter Score) questions

NPS is the only question type that ships with a non-generic default title baked into the Forms UI, so Add-MSFormNPSQuestion defaults to it too:

Adding Sections

A section turns out to follow the same pattern as every question type in this post: a generic POST to a sub-collection – descriptiveQuestions rather than questions – followed by a PATCH to fill in the details. Add-MSFormSection creates a bare placeholder section (type/title/id/order/isQuiz only, no questionInfo), and Update-MSFormSection below is what actually sets its real title and subtitle:

Once created, the section shows up as a descriptiveQuestions entry on the form, which is what Update-MSFormSection targets to set its title/description and move it, using the same sparse order scheme as questions.

Configuring Form Settings

Everything under the Forms “Settings” panel – who can respond, scheduling, limits, and notifications – is a PATCH to the form’s own resource URL, just with a different settings payload each time.

Who Can Respond

Set-MSFormResponseMode deliberately mirrors New-MSForm‘s ResponseMode parameter, since it’s setting the exact same three options after the fact instead of at creation time:

Worth flagging: the captured responderPermissions call itself doesn’t take a list of people – its principalId is literally the string "SpecificResponder", which reads more like a mode-switch (turn on “specific responders only”) than a per-person grant. The actual list of named respondents appears to be handled separately through the same /permissions sharing endpoint used for collaborators below. I’ve combined both calls above since that matches how New-MSForm uses this function, but if your captures show something different for adding named respondents, treat that part as the least certain piece of this whole write-up. Bear in mind Forms won’t let you use Anyone mode if the form contains a File Upload question.

Scheduling & Access Windows

The Forms UI doesn’t let you set a schedule on a form that’s currently closed, so Set-MSFormSchedule uses parameter sets to keep “close it now” and “schedule a window” mutually exclusive:

Timeout / Auto-Submit
Display Options

Passing an empty -ThankYouMessage reverts to the default “Your response has been submitted” screen:

Respondent Permissions

Save-and-resume versus edit-after-submit are both controlled by the same numeric PermissionForResponder field, so Set-MSFormResponderCapabilities hides the numbers behind a named set. Note edit-after-submit isn’t available once a time limit is set:

Notifications & Collaborators

Notifying the form owner by email on every new response is a two-step process: first grant a collaborator access via the /permissions sharing endpoint, then that person’s permission set is updated to include EmailNotification. Add-MSFormCollaborator does both in one call when you pass -NotifyByEmail:

Reading Current Settings

The easiest way to confirm any of the changes above actually landed:

Deleting a Form

Deleting follows the same rule as every other resource in this API (questions, choices, statements): a DELETE against the resource’s own URL removes it, and a form is no exception. Remove-MSForm wraps that in a plain DELETE to the form’s endpoint, guarded behind -Confirm since it’s destructive:

The -Confirm is optional rather than load-bearing here – ConfirmImpact = "High" already means PowerShell will prompt by default at the standard $ConfirmPreference level, so it’s really there as a reminder that this one is one-way.

Final Thoughts

Between Part 1 and this post, that’s the full loop covered – read forms and responses, then create, configure, and delete forms, all from PowerShell and without touching the Forms web UI.

The API surface is bigger than either post lets on; things like conditional branching between questions, quiz scoring, and collaborator management have more depth than there was room to cover here.

As with everything in this series, none of it is documented or supported by Microsoft, so build in your own error handling and don’t assume today’s request shapes will still work tomorrow.

I’m currently working on packaging all of this up as a proper PowerShell module, along with the functionality that hasn’t made it into either post – including the deeper areas mentioned above. Once it’s ready, I’ll release it as Part 3 and make the module available on GitHub.

No comments yet