June 2, 2026
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 created. The usual workaround is an IT admin creating a folder on a shared system account and adding the user as an editor. That leaves the admins (and the system account) with standing access to every employee's files, and anything a user deletes lands in the system account's trash instead of their own.
Set up + Required permissions: A Box Enterprise account, and admin access to Azure and Box. An identity provider (IdP) with SSO is assumed; Entra ID is used as the example in the optional Part 1. You also need the right to create resources in an Azure subscription (Azure roles are separate from Entra roles, so an Entra admin role alone is not enough).
Solution: An Azure Function watches Box for newly created users. For each one it creates a folder, hands ownership to that user and removes its own access, so the home folder is natively owned by the employee and not created (and inherently accessible) by an admin on a system account. It does not matter how the account was created: Entra provisioning, another IdP, or an admin adding the user by hand.
Scope: This covers new users only. Existing folders are not touched, and the function does not create the Box accounts themselves. Moving existing folders to their owners is a different problem with its own pitfalls (an admin-owned folder can only be handed over by its current owner), and is not covered here.
NEW_USER events. This is polling, not a webhook: Box webhooks are tied to files and folders, so there is no clean webhook for account creation. Expect the folder to appear within roughly 15 minutes plus a few minutes for Box to publish the event. Because it reads Box's own event log, it works regardless of how the account was created. The code is plain Node.js, so it can be moved to another runtime later.This part only creates the Box accounts. The home-folder function in the rest of this post works without it, so if accounts are created by hand or another way, skip to Part 2. If you turn provisioning on, run it through your change-management process, because it changes a production system.
Check these before you start:
userPrincipalName or mail). If an existing Box account uses a different login than the Entra identity, provisioning will create a second account instead of recognizing the first.IsMemberOf) is not supported, so a filter cannot say "only members of this group".Steps:
Provisioning can be paused from the Overview page without deleting already-provisioned Box accounts. Disabling it stops new syncs but does not retroactively remove existing users.
invalid_client.AutomationUser_<app id>_<random>@boxdevedition.com). It is separate from any existing system account you have. With 'App + Enterprise Access' it can invite any user, but it can only see content it created or was invited to. That is why the folder is created by the service account itself.# 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".
npm -v prints something much older, update it with npm install -g npm@latest.# 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
β
ββββlib
boxClient.js
checkpoint.js
homeFolder.js
{
"name": "box-home-folder-function",
"version": "1.0.0",
"description": "Provisions a Box home folder for new users, owned by them, with no standing access for admins or the service account.",
"main": "src/functions/*.js",
"scripts": {
"start": "func start"
},
"dependencies": {
"@azure/functions": "^4.5.0",
"@azure/data-tables": "^13.2.2",
"box-node-sdk": "^4.13.0"
}
}
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
If func start later warns that '1.0.0' is an invalid value for host.json 'version', the file has the wrong content. The version must be "2.0".
local.settings.json and fill in your real values; never commit that file){
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "PASTE_REAL_STORAGE_CONNECTION_STRING_HERE",
"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"
}
}
AzureWebJobsStorage can stay UseDevelopmentStorage=true for the manual test in Part 4a, but the timer trigger in Part 4b stores its position in Azure Table Storage, so it needs the real connection string you saved in Part 2.
const { BoxClient, BoxCcgAuth, CcgConfig } = require('box-node-sdk/sdk-gen');
/**
* Builds a Box client authenticated as the service account via CCG.
* Reads credentials from environment / Application Settings.
*/
function getBoxClient() {
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 });
return new BoxClient({ auth: ccgAuth });
}
module.exports = { getBoxClient };
/**
* Core provisioning sequence:
* 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", so ownership transfers to the user
* and the 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.
*
* @param {import('box-node-sdk/sdk-gen').BoxClient} client
* @param {string} userId - Box user ID
* @param {string} userName - display name, used for the friendly folder name
* @param {{log: Function, warn: Function}} logger - context.log/context.warn from the caller
* @returns {Promise<{folderId: string, folderName: string, ownedBy: string}>}
*/
async function provisionHomeFolder(client, userId, userName, logger) {
// '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';
const uniqueFolderName = String(userId);
const friendlyFolderName = `${userName} - Home`;
// 1. Create the folder, named by user ID. Reuse on conflict.
let folder;
try {
folder = await client.folders.createFolder({
name: uniqueFolderName,
parent: { id: parentFolderId },
});
logger.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) {
logger.warn(`Folder "${uniqueFolderName}" already exists (id ${conflictId}), reusing it instead of failing.`);
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',
});
logger.log(`Added user ${userId} as editor (collaboration ${collaboration.id})`);
// 3. Rename to the friendly display name. Cosmetic only, so non-fatal.
try {
await client.folders.updateFolderById(folder.id, {
requestBody: { name: friendlyFolderName },
});
logger.log(`Renamed folder ${folder.id} to "${friendlyFolderName}"`);
} catch (renameErr) {
logger.warn(`Could not rename folder ${folder.id}, continuing anyway. Error: ${renameErr.message}`);
}
// 4. Upgrade that collaboration to owner. The service account is
// automatically demoted to editor on this folder as a side effect.
await client.userCollaborations.updateCollaborationById(collaboration.id, {
requestBody: { role: 'owner' },
});
logger.log(`Transferred ownership of folder ${folder.id} to user ${userId}`);
// 5. Find and remove the service account's own (now editor) collaboration.
const collabs = await client.listCollaborations.getFolderCollaborations(folder.id);
const me = await client.users.getUserMe();
const ownCollab = collabs.entries.find(
(c) => c.accessibleBy && c.accessibleBy.id === me.id
);
if (ownCollab) {
await client.userCollaborations.deleteCollaborationById(ownCollab.id);
logger.log(`Removed service account's collaboration (${ownCollab.id})`);
} else {
logger.warn('Could not find service account collaboration to remove, verify manually in Box.');
}
return { folderId: folder.id, folderName: friendlyFolderName, ownedBy: userId };
}
module.exports = { provisionHomeFolder };
π‘ Note
A few things about the Box SDK that cost time to find out. Import from
box-node-sdk/sdk-gen(the bare package exports something else).updateCollaborationByIdandupdateFolderByIdtake their body wrapped in{ requestBody: {...} }, whilecreateCollaborationtakes it directly. If you pass the role directly toupdateCollaborationById, Box receives an empty body and answers with a 400. Ownership can only be handed over by the current owner, which is why the service account creates the folder itself.
const { app } = require('@azure/functions');
const { getBoxClient } = require('../lib/boxClient');
const { provisionHomeFolder } = require('../lib/homeFolder');
/**
* Manual test endpoint. Calls the same provisionHomeFolder logic the timer
* trigger (checkNewUsers.js) uses in production, kept here for one-off
* testing against a specific user without waiting for a real Box event.
*
* 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' },
};
}
try {
const client = getBoxClient();
const outcome = await provisionHomeFolder(client, userId, userName, context);
return { status: 200, jsonBody: outcome };
} catch (err) {
context.error('Home folder provisioning failed:', err);
return { status: 500, jsonBody: { error: err.message } };
}
},
});
const { TableClient } = require('@azure/data-tables');
const TABLE_NAME = 'BoxProvisioningCheckpoints';
const PARTITION_KEY = 'box';
const ROW_KEY = 'newUserStream';
function getTableClient() {
const connectionString = process.env.AzureWebJobsStorage;
return TableClient.fromConnectionString(connectionString, TABLE_NAME);
}
async function ensureTableExists(tableClient) {
try {
await tableClient.createTable();
} catch (err) {
// Already exists, which is the expected steady state after the first run.
if (err.statusCode !== 409) {
throw err;
}
}
}
/**
* Returns the saved stream position, or null if this is the first run
* (no checkpoint saved yet).
*/
async function getCheckpoint() {
const tableClient = getTableClient();
await ensureTableExists(tableClient);
try {
const entity = await tableClient.getEntity(PARTITION_KEY, ROW_KEY);
return entity.streamPosition;
} catch (err) {
if (err.statusCode === 404) {
return null;
}
throw err;
}
}
async function saveCheckpoint(streamPosition) {
const tableClient = getTableClient();
await ensureTableExists(tableClient);
await tableClient.upsertEntity(
{
partitionKey: PARTITION_KEY,
rowKey: ROW_KEY,
streamPosition: String(streamPosition),
},
'Replace'
);
}
module.exports = { getCheckpoint, saveCheckpoint };
const { app } = require('@azure/functions');
const { getBoxClient } = require('../lib/boxClient');
const { provisionHomeFolder } = require('../lib/homeFolder');
const { getCheckpoint, saveCheckpoint } = require('../lib/checkpoint');
const MAX_PAGES_PER_RUN = 5; // safety cap so one run can't loop indefinitely
app.timer('checkNewUsers', {
// Every 15 minutes. Adjust to how quickly new hires need their folder.
schedule: '0 */15 * * * *',
handler: async (myTimer, context) => {
const client = getBoxClient();
const savedPosition = await getCheckpoint();
if (savedPosition === null) {
// First run ever: catch the checkpoint up to the present without
// provisioning anything for existing history. We don't want to
// retroactively provision every existing employee in one go.
// Walks forward through the stream, discarding entries instead of
// processing them. (createdAfter is avoided on purpose: the SDK
// wants a wrapped date object for it, which is easy to get wrong.)
let bootstrapPosition = '0';
for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {
const result = await client.events.getEvents({
streamType: 'admin_logs',
streamPosition: bootstrapPosition,
eventType: ['NEW_USER'],
limit: 100,
});
bootstrapPosition = result.nextStreamPosition ?? bootstrapPosition;
if (!result.entries || result.entries.length < 100) {
break; // caught up
}
}
await saveCheckpoint(bootstrapPosition);
context.log(`First run: walked forward and seeded checkpoint at position ${bootstrapPosition}. No events processed this run.`);
return;
}
let position = savedPosition;
let processedCount = 0;
for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {
const result = await client.events.getEvents({
streamType: 'admin_logs',
streamPosition: position,
eventType: ['NEW_USER'],
limit: 100,
});
if (!result.entries || result.entries.length === 0) {
position = result.nextStreamPosition ?? position;
break;
}
for (const event of result.entries) {
const userId = event.source?.id;
const userName = event.source?.name;
if (!userId || !userName) {
context.warn(`Skipping NEW_USER event with missing source info: ${JSON.stringify(event)}`);
continue;
}
try {
const outcome = await provisionHomeFolder(client, userId, userName, context);
context.log(`Provisioned home folder for ${userName} (${userId}): folder ${outcome.folderId}`);
processedCount++;
} catch (err) {
// Log and continue: one bad user shouldn't block the rest of
// the batch or stall the checkpoint forever. Failed users will
// need manual follow-up.
context.error(`Failed to provision home folder for ${userName} (${userId}):`, err);
}
}
position = result.nextStreamPosition ?? position;
// If we got fewer than a full page, we're caught up.
if (result.entries.length < 100) {
break;
}
}
await saveCheckpoint(position);
context.log(`Run complete. Processed ${processedCount} new user(s). Checkpoint saved at ${position}.`);
},
});
π‘ Note
The stream type is
admin_logson purpose. Box also offersadmin_logs_streaming, which is faster but can deliver the same event twice, and this function does not check whether a user already has a home folder.admin_logsis slower (a few minutes) but does not deliver duplicates.
cd path\to\box-homedir-automation
npm install
package.json file and will pull in @azure/functions, @azure/data-tables and box-node-sdk, the packages the code needs.func start
rmdir /s /q node_modules
del package-lock.json
npm install
checkNewUsers timer is used in Part 4b):[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."}}
func start running and use a second terminal):# Mac OS, Linux:
curl -X POST http://localhost:7071/api/createHomeFolder -H "Content-Type: application/json" -d '{"userId": "PASTE_ID_HERE", "userName": "Test User"}'
# Powershell:
try {
Invoke-RestMethod -Uri http://localhost:7071/api/createHomeFolder -Method Post -ContentType "application/json" -Body '{"userId": "123456789", "userName": "TestUser"}'
} catch {
$_.ErrorDetails.Message
}
curl is an alias for a different command, so use Invoke-RestMethod there. The try/catch prints the error body, which Invoke-RestMethod otherwise hides.func start' was triggered and is waiting for requests.Let's simulate an automated trigger to verify that it works as expected.
local.settings.json as AzureWebJobsStorage. The timer function keeps its position in Azure Table Storage, so the local emulator value does not work for it. This also means your local runs share the checkpoint with the deployed app, which is intended. It also means that a local func start keeps polling on its own schedule, so stop it when you are done testing.func app is running (still locally), run an empty command from the terminal. The first run only saves the current position in the event stream and processes nothing. That is by design, so existing users are not touched.# 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. The code only reads environment variables, so you can deploy first with plain app settings and switch to Key Vault later without changing any code. Key Vault is recommended because this function runs unattended with write access to Box.
BoxHomeDir (your Function app)<your-vault-name>BOX-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://<your-vault-name>.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 as in the local file):
BOX_CLIENT_IDBOX_CLIENT_SECRETBOX_ENTERPRISE_IDBOX_HOME_FOLDER_PARENT_ID (0 puts the folders in the enterprise root)π‘ Note
If you use Key Vault, do not save the actual values here, rather use references to the Key Vault, such as
@Microsoft.KeyVault(SecretUri=...). The setting then holds a pointer to the secret, not a second copy of it. If you have to start with plain values because the vault does not exist yet, swap them for references later: at no point should both exist at the same time.Click Apply/Save on the Environment variables page. Unsaved values look fine in the portal but the function then fails with
invalid_client.
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 tools (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
remotebuild = false. That means the node_modules folder from your machine was uploaded as it is, instead of being built again in Azure. That is fine here because every dependency is plain JavaScript. If you later add a package with native code, a copy built on Windows may not run on Linux, so build remotely in that case (see the --build option in the Azure Functions Core Tools documentation)..funcignore file, which the publish command respects.Let's give it a test using an existing user in Box to confirm that it can create a HomeDir and change the ownership. The timer runs on its own every 15 minutes, so afterwards we still need to test it with a brand-new user.
BoxHomeDir and go to Functions β createHomeFolder β Get Function URL (this gives you the full URL with ?code=... already appended, no need to hunt for the key separately). The button is greyed out for checkNewUsers, because timer functions have no URL. Choose the default (Function) key, not _master or the host key, which are much more powerful. Then:Invoke-RestMethod -Uri "https://<your-function-app>.<region>.azurewebsites.net/api/createHomeFolder?code=<your-function-key>" -Method Post -ContentType "application/json" -Body '{"userId": "<box-user-id>", "userName": "TestUserDeployed"}'
?code=... is a secret. Anyone who has it can call the function. Do not paste it into screenshots, tickets or posts, and renew the key in the Portal (Functions β App keys) if it ever leaks.invalid_client, the Box settings were probably not saved on the Function app (see the note in Part 5).storage_limit_exceeded as they fill it.Enjoying this tutorial?
This site is a non-profit project. If it saved you some time, you can support it by getting Jan some coffee.
☕ Buy me a coffee
Comments