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.
# 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".
# 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
# 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
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
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.
func start
rmdir /s /q node_modules
del package-lock.json
npm install
[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."}}
# 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"}'
func start’ was triggered and is waiting for requests.Let’s simulate an automated trigger to verify that it works as expected.
func app is running (still locally), create a Box test user.# 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
To make the Function app work in Azure with credentials, we will need to pass credentials safely using best security practices.
BoxHomeDir (Azure subscription)SIS-Boxsis-box-automation-kvBOX-CLIENT-IDBOX-CLIENT-SECRETBOX-ENTERPRISE-IDAzureWebJobsStorage 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_IDBOX_CLIENT_SECRETBOX_ENTERPRISE_IDBOX_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.
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.
# 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
func azure functionapp publish BoxHomeDir
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).
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"}'
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"}'