How to programmatically create home folders for new users on Box with Azure Functions

Download Markdown

Problem statement: If root folders are not allowed in Box by an IT policy, new users get no access to any files once their accounts are provisioned.

Set up + Required permissions: Entra ID IdP + SSO, Box Enterprise account, admin access to Azure, Entra and Box required.

Solution: Use automatic provisioning triggered by Entra ID app assignment, so home folders are natively owned by the employee, not created (and inherently accessible) by an admin on a system account.

The workflow

  1. Trigger: webhook from Box on a new Box user account creation independent of the IdP used that fires into an Azure Function app (this can be moved to another runtime environment later if Azure’s environment is migrated from).
  2. The app sitting in the Azure Function’s runtime will use Box API calls to create a folder called according to the user’s ID in Box (to be unique) using a system account.
  3. Ownership to that folder is transferred to the newly created user.
  4. Folder name is changed from user’s ID to the actual user’s name and ‘home folder’ is added.
  5. System account’s access to that folder is removed.

Part 1 (CM): Entra ID app set up

  1. This is part of a CM because we are making a change in a production system. In Entra admin center, go to the Box app.
  2. Go to the Provisioning tab → New configuration. Proceed through the Azure portal.
  3. Under Admin Credentials, click Authorize. This opens a Box login window. Sign in with the Team Admin account and grant access.
  4. Click Test Connection to confirm Entra can reach Box's provisioning API.
  5. Click Create to save the configuration.
  6. Go to Properties → enable notification emails (route to the IT team) and turn on Accidental deletions prevention (to safeguard against the provisioning engine bulk-disabling or deleting Box user accounts in a single sync cycle). Save.
  7. Go to Attribute Mapping → Users → review the default attribute mappings (email as matching key, name, department, etc.) and map them to the ones from Box.
  8. (Optional:) We can set up a scoping filter to limit auto-provisioning by department/OU rather than flat group membership. This could be good for testing.
  9. Use on-demand provisioning to test against 1–2 pilot users before wider rollout. Verify the Box account and home folder are created automatically and owned by the user, not by the authorizing admin account.
  10. Once validated, go to Overview → Start Provisioning.
  11. Confirm synced users appear under Managed Users in the Box Admin Console.

Part 2: Custom app in Box + Azure Function app

  1. Create a custom app in Box: Go to account.box.com/developers/console and create a new app with the following parameters:
    1. Server Authentication (Clients Credentials grant)
    2. With this name: SIS-BoxHomeFolder-Automation
    3. Set access to ‘Apps + Enterprise'.
    4. Scope access - set only these: ‘Read all files and folders’, ‘Write all files and folders’, ‘Manage enterprise properties’ (to build the Events API poller).
    5. Save the client ID and secret, along with the Enterprise ID, in your team's password manager.
    6. Note: There is no CORS to set as this is a server-to-server interaction.
  2. Once done, ensure that you click on the ‘Authorize’ status to push it for real use, otherwise requests sent to it will be rejected.
  3. Set up a Function app in MS Azure with these settings:
    1. Node version: 22 (or newest)
    2. Memory: the smallest (512 MB is default)
    3. Once the app is running, go to Environmental Variables and save the ‘AzureWebJobsStorage’ value - it will be used later.

1 part 2 custom app in box

Part 3: Node JS app prep

  • On your device, download Node JS v22 (as per your Azure Function app setting) with the newest version of npm. On a Win device, you can use Chocolatey:
# Source: https://nodejs.org/en/download

# Download and install Chocolatey:
powershell -c "irm https://community.chocolatey.org/install.ps1|iex"
# Download and install Node.js:
choco install nodejs --version="22.23.2"
# Verify the Node.js version:
node -v # Should print "v22.23.2".
# Verify npm version:
npm -v # Should print "10.9.8".
  • Install Azure tools
# Some parts will be blocked by default, no issues with that.
npm install -g azure-functions-core-tools@4

# Should print a 4.x version number. Will be downloaded if blocked previously.
func --version

# Output should be version 4.x, such as 4.1.2
  • Create the following structure in your preferred IDE:
# tree /F        
box-homedir-automation/
│   host.json
│   local.settings.json
│   local.settings.json.example
│   package-lock.json
│   package.json
│   readme.md
│
└───src
    ├───functions
    │       checkNewUsers.js
    │       createHomeFolder.js
    │       readme.md
    │
    └───lib
            boxClient.js
            checkpoint.js
            homeFolder.js
  • package.json
{
  "name": "box-home-folder-function",
  "version": "1.0.0",
  "description": "Creates a Box home folder for a user and transfers ownership to them, leaving the service account with no standing access.",
  "main": "src/functions/*.js",
  "scripts": {
    "start": "func start"
  },
  "dependencies": {
    "@azure/functions": "^4.5.0",
    "box-node-sdk": "^4.0.0"
  }
}
  • host.json
{
  "name": "box-home-folder-function",
  "version": "2.0",
  "description": "Test harness: creates a Box home folder for a user and transfers ownership to them, leaving the service account with no standing access.",
  "main": "src/functions/*.js",
  "scripts": {
    "start": "func start"
  },
  "dependencies": {
    "@azure/functions": "^4.5.0",
    "box-node-sdk": "^4.0.0"
  }
}
  • local.settings.json.example
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "node",

    "BOX_CLIENT_ID": "your-box-custom-app-client-id",
    "BOX_CLIENT_SECRET": "your-box-custom-app-client-secret",
    "BOX_ENTERPRISE_ID": "your-box-enterprise-id",

    "BOX_HOME_FOLDER_PARENT_ID": "0"
  }
}
  • src\functions\createHomeFolder.js
const { app } = require('@azure/functions');
const { BoxClient, BoxCcgAuth, CcgConfig } = require('box-node-sdk/sdk-gen');

/**
 * Test harness for the Box home-folder pattern:
 *   1. Service account creates a folder named by user ID (collision-safe;
 *      reuses an existing folder of the same name instead of failing, to
 *      survive a retry after a previous interrupted run)
 *   2. New user is added as a collaborator (editor)
 *   3. Folder is renamed to a friendly display name (best-effort, non-fatal)
 *   4. That collaboration is upgraded to "owner" -> ownership transfers to the user,
 *      service account is automatically demoted to editor on this folder
 *   5. The service account's now-editor collaboration is deleted
 *
 * End state: the user owns the folder outright. No admin identity and no
 * service account retains standing access to it.
 *
 * Call with POST body: { "userId": "<box user id>", "userName": "<display name>" }
 */
app.http('createHomeFolder', {
  methods: ['POST'],
  authLevel: 'function',
  handler: async (request, context) => {
    let body;
    try {
      body = await request.json();
    } catch {
      return {
        status: 400,
        jsonBody: { error: 'Expected a JSON body with userId and userName' },
      };
    }

    const { userId, userName } = body;
    if (!userId || !userName) {
      return {
        status: 400,
        jsonBody: { error: 'userId and userName are both required' },
      };
    }

    const ccgConfig = new CcgConfig({
      clientId: process.env.BOX_CLIENT_ID,
      clientSecret: process.env.BOX_CLIENT_SECRET,
      enterpriseId: process.env.BOX_ENTERPRISE_ID,
    });
    const ccgAuth = new BoxCcgAuth({ config: ccgConfig });
    const client = new BoxClient({ auth: ccgAuth });

    // '0' = enterprise root. Set BOX_HOME_FOLDER_PARENT_ID if home folders
    // should nest under a specific parent instead.
    const parentFolderId = process.env.BOX_HOME_FOLDER_PARENT_ID || '0';

    // Name by user ID first, not the friendly name. User IDs are unique
    // enterprise-wide, so this can never collide across different users,
    // unlike a display-name-based folder name, which breaks the moment two
    // people share a name or a run gets retried. Friendly name gets applied
    // later, after the folder is confirmed usable.
    const uniqueFolderName = String(userId);
    const friendlyFolderName = `${userName} - Home`;

    try {
      // 1. Create the folder, named by user ID. Service account owns it at
      //    this point. If this exact folder already exists, most likely
      //    because a previous run for this same user got interrupted after
      //    creating it but before finishing, reuse it instead of failing.
      //    Box's 409 response hands back the conflicting item's ID directly.
      let folder;
      try {
        folder = await client.folders.createFolder({
          name: uniqueFolderName,
          parent: { id: parentFolderId },
        });
        context.log(`Created folder ${folder.id} (named "${uniqueFolderName}")`);
      } catch (createErr) {
        const conflictId = createErr.contextInfo?.conflicts?.[0]?.id;
        if (createErr.message?.includes('item_name_in_use') && conflictId) {
          context.warn(`Folder "${uniqueFolderName}" already exists (id ${conflictId}), reusing it instead of failing. This is expected if a previous run for this user was interrupted partway through.`);
          folder = await client.folders.getFolderById(conflictId);
        } else {
          throw createErr;
        }
      }

      // 2. Invite the user as a collaborator so ownership can be handed over.
      const collaboration = await client.userCollaborations.createCollaboration({
        item: { type: 'folder', id: folder.id },
        accessibleBy: { type: 'user', id: userId },
        role: 'editor',
      });
      context.log(`Added user ${userId} as editor (collaboration ${collaboration.id})`);

      // 3. Rename to the friendly display name, while the service account
      //    still owns the folder. Cosmetic only, if this fails for any
      //    reason, log it and keep going rather than aborting. A folder
      //    that's correctly owned but still named by user ID is a much
      //    smaller problem than one that's named right but never handed off.
      //    (updateFolderById follows the same requestBody-wrapping pattern
      //    confirmed for updateCollaborationById, not yet independently
      //    verified against source; if this throws, check it the same way.)
      try {
        await client.folders.updateFolderById(folder.id, {
          requestBody: { name: friendlyFolderName },
        });
        context.log(`Renamed folder ${folder.id} to "${friendlyFolderName}"`);
      } catch (renameErr) {
        context.warn(`Could not rename folder ${folder.id} to "${friendlyFolderName}", continuing anyway. Error: ${renameErr.message}`);
      }

      // 4. Upgrade that collaboration to owner. Ownership transfers; the
      //    service account is automatically demoted to editor on this folder.
      await client.userCollaborations.updateCollaborationById(collaboration.id, {
        requestBody: { role: 'owner' },
      });
      context.log(`Transferred ownership of folder ${folder.id} to user ${userId}`);

      // 5. Find and remove the service account's own (now editor) collaboration
      //    on this folder, so nothing but the user has standing access.
      const collabs = await client.listCollaborations.getFolderCollaborations(folder.id);
      const me = await client.users.getUserMe();
      context.log('Collaborations on folder after transfer:', JSON.stringify(collabs.entries));

      const ownCollab = collabs.entries.find(
        (c) => c.accessibleBy && c.accessibleBy.id === me.id
      );
      if (ownCollab) {
        await client.userCollaborations.deleteCollaborationById(ownCollab.id);
        context.log(`Removed service account's collaboration (${ownCollab.id})`);
      } else {
        context.warn('Could not find service account collaboration to remove, check the logged entries above and verify manually in Box.');
      }

      return {
        status: 200,
        jsonBody: { folderId: folder.id, folderName: friendlyFolderName, ownedBy: userId },
      };
    } catch (err) {
      context.error('Home folder provisioning failed:', err);
      return { status: 500, jsonBody: { error: err.message } };
    }
  },
});
  • In your IDE, go to the folder and run ‘npm install’:

    cd path\to\box-homedirautomation
    npm install
  • This command will read the package.json file and will pull in @azure/functions and box-node-sdk, the two packages the script actually needs.

Part 4a: Test the app - Manual trigger

  • While in the folder with the cloned code, run the following:
func start
  • If you get an error about missing files, make sure you are not using a network or otherwise slower storage, clean up and try again:
rmdir /s /q node_modules
del package-lock.json
npm install
  • You should see something like this:

2 part 4a test the app manual

  • In case you get warnings related to a health check, then do not worry, we did not install Azurite for the health checks, you can ignore it:
[2026-08-01T12:47:23.304Z] [Tag=''] Process reporting unhealthy: Unhealthy. Health check entries are {"azure.functions.web_host.lifecycle":{"status":"Healthy","description":null},"azure.functions.script_host.lifecycle":{"status":"Healthy","description":null},"azure.functions.webjobs.storage":{"status":"Unhealthy","description":"A timeout occurred while running check."}}
  • Get your test user's Box ID: Box Admin Console → Users → click on your test user → the numeric user ID is in the URL or below in the user details.
  • Call the function locally:
# Mac OS, Linux:
curl -X POST http://localhost:7071/api/createHomeFolder -H "Content-Type: application/json" -d "{\"BoxUserId\": \"PASTE_ID_HERE\", \"userName\": \"Test User\"}"

# Powershell:
Invoke-RestMethod -Uri http://localhost:7071/api/createHomeFolder -Method Post -ContentType "application/json" -Body '{"userId": "123456789", "userName": "TestUser"}'
  • In case you get a ‘Unable to connect to the remote server’ error, make sure that ‘func start’ was triggered and is waiting for requests.
  • ‘Error 404 - not found’ → ensure the user ID is correct, find it in the Box admin interface (not in Entra).
  • ‘Error 409 - Item name in use‘ or ‘Item with the same name already exists’→ find a duplicate folder name called ‘TestUser - Home’ in Box and remove it first.
  • If you get ‘The remote server returned an error: (500) Internal Server Error.’, then go to the func start’s log in the terminal and check what the error is. If you see ‘Error 400: Invalid_client’, do the following:
    • Verify that your client ID and secret are correct.
    • Check whether the app is actually authorized yet. Go to the Developer Console and check its settings and status. It should read as App + Enterprise Access and be in an approved state; if that app is sitting in a "pending" or "not yet authorized" state, every token request fails with exactly this error, credentials notwithstanding.

Part 4b: Test the app - Automated trigger

Let’s simulate an automated trigger to verify that it works as expected.

  • While the func app is running (still locally), create a Box test user.

3 part 4b test the app

  • Then from the terminal, run an empty command:
# Powershell (Win)
Invoke-RestMethod -Uri http://localhost:7071/admin/functions/checkNewUsers -Method Post -ContentType "application/json" -Body '{"input": ""}'

# Curl (Mac OS, Linux)
curl -X POST http://localhost:7071/admin/functions/checkNewUsers -H "Content-Type: application/json" -d '{"input": ""}' | python3 -m json.tool
  • Wait 2-3 minutes, as there is a delay. Watch the log. It should spot that a new account was created in Box, create a folder based on its ID and transfer ownership.

4 part 4b test the app

Part 5: Create a Key Vault + upload credentials as env variables

To make the Function app work in Azure with credentials, we will need to pass credentials safely using best security practices.

  • In Azure, search for "Key Vault" → Create
    • Subscription: same one as BoxHomeDir (Azure subscription)
    • Resource group: SIS-Box
    • Vault name: something like sis-box-automation-kv
    • Region: same as the Function App (West US 3)
    • Everything else: defaults are fine
  • Add the three secrets (as saved in Bitwarden):
    • BOX-CLIENT-ID
    • BOX-CLIENT-SECRET
    • BOX-ENTERPRISE-ID
    • Note: Key Vault secret names can't contain underscores (hyphens are allowed). These variables will be mapped later on.
  • Let the Function App identify itself to the vault: in the Function App (BoxHomeDir), go to Settings → Identity → System assigned → On → Save. This gives the Function App its own identity in Entra, so it can authenticate to Key Vault without a stored credential.
  • Grant that identity permission to read secrets: back in the Key Vault, go to Access control (IAM) → Add role assignment → role: Key Vault Secrets User → assign to: Managed identity → select Function App → pick BoxHomeDir → Save.
  • Point the app settings at the vault instead of plaintext: In the Function App, go to Settings → Environment variables (same place you found AzureWebJobsStorage earlier). Edit BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID - replace each plaintext value with:
# Swap the secret name in the URI for each of the three & hit save.
@Microsoft.KeyVault(SecretUri=https://sis-box-automation-kv.vault.azure.net/secrets/BOX-CLIENT-ID/)

The credentials saved in the local.settings.json file never get uploaded. That file is explicitly local-only. We will need to create them directly: in the BoxHomeDir app, go to Settings → Environment variables → add each one manually (same names, same values as in the local file):

  • BOX_CLIENT_ID
  • BOX_CLIENT_SECRET
  • BOX_ENTERPRISE_ID
  • BOX_HOME_FOLDER_PARENT_ID

💡 Note

Do not save the actual values here, rather use references to the Key Vault, such as @Microsoft.KeyVault(SecretUri=...). This way, the actual values are stored safely in the Key Vault according to best security practices.

Note: AzureWebJobsStorage should already be set, that one Azure provisioned automatically.

5 part 5 create a key vault

Part 6: From local to cloud

For now, the code was executed on our station locally. Now it’s the time to take it into the runtime created in the Function app in Azure. Just to clarify again, this is independent from what IdP is used, it can be used with Entra as well as with Ping or another.

  • In your project root within your preferred IDE (such as Visual Studio Code), use the Azure CLI to code the app to the Function app:
# Install the Azure CLI ttools (winget if on Windows) - follow [<u>these steps</u>](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-macos?view=azure-cli-latest) on MacOS.
winget install Microsoft.AzureCLI

# Log into the Azure account - choose the preferred subscription
az login

6 past 6 from local to cloud

  • Push the code to the Azure Function app
func azure functionapp publish BoxHomeDir

7 past 6 from local to cloud

Let’s give it a test using an existing user in Box to confirm that it can create a HomeDir and change the ownership (then later, we still need to test the webhook on NEW_USER).

  • In the Azure Portal, find the BoxHomeDir and go to Overview and scroll down to Functions → createHomeFolder → Get Function URL (this gives you the full URL with ?code=... already appended, no need to hunt for the key separately). Copy the default (Function) key. Then:
Invoke-RestMethod -Uri "" -Method Post -ContentType "application/json" -Body '{"userId": "123456789", "userName": "TestUser"}'
  • A real-life example:
Invoke-RestMethod -Uri "https://boxhomedir-djdecma2aphaf0cp.westus3-01.azurewebsites.net/api/createHomeFolder?code=<your-function-key>" -Method Post -ContentType "application/json" -Body '{"userId": "52179199170", "userName": "TestUserDeployed"}'
  • The expected output is this:

8 past 6 from local to cloud

Verification

  • After test-user provisioning, confirm the Box account exists and is owned by the user (check via Admin Console → Users → [test user] → Content, not via [email protected]'s own file list).
  • Confirm the folder has no unexpected collaborators, it should be empty or just the user, not carrying over any admin as a collaborator.
  • Confirm no legacy shared structure got attached automatically.
  • Provisioning can be paused from the Overview page without deleting already-provisioned Box accounts; disabling it stops new syncs but doesn't retroactively remove existing users.