A complete guide to setting up Box OAuth authentication in a Nuxt 4 application with progressive consent - users start with read-only access and upgrade to write access only when needed.
- Overview
- Prerequisites
- Box Developer Console Setup
- Project Setup
- File Structure
- Configuration
- Type Definitions
- OAuth Handlers
- API Endpoints
- Frontend Pages
- User Flow
- Troubleshooting
This implementation uses a progressive OAuth consent pattern:
| Step | Scope | Description |
|---|---|---|
| 1. Initial Login | root_readonly |
User logs in with minimal permissions (read files/folders) |
| 2. Upload Request | root_readwrite |
When user wants to upload, they're prompted to grant write access |
This approach:
- β Doesn't scare users with excessive permissions upfront
- β Only requests write access when actually needed
- β Stores scope in session for server-side validation
- Node.js 18+
- pnpm (recommended) or npm
- A Box account (free or enterprise)
- A Box Developer application
- Go to Box Developer Console
- Click "Create New App"
- Select "Custom App"
- Choose "User Authentication (OAuth 2.0)"
- Name your app and click "Create App"
In your app's Configuration tab, under Application Scopes, enable:
| Scope | Required For |
|---|---|
| βοΈ Read all files and folders stored in Box | Basic login, browsing |
| βοΈ Read and write all files and folders stored in Box | File uploads |
β οΈ Important: If you don't enableroot_readwritescope here, requesting it via OAuth will fail withinvalid_scopeerror.
In the Configuration tab, under OAuth 2.0 Redirect URI, add:
http://localhost:3000/auth/box
http://localhost:3000/auth/box-upgrade
For production, also add:
https://yourdomain.com/auth/box
https://yourdomain.com/auth/box-upgrade
Note down from the Configuration tab:
- Client ID
- Client Secret
npx nuxi@latest init my-box-app
cd my-box-apppnpm add nuxt-auth-utils// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
devtools: { enabled: true },
modules: ['nuxt-auth-utils'],
runtimeConfig: {
oauth: {
box: {
clientId: '', // Set via NUXT_OAUTH_BOX_CLIENT_ID
clientSecret: '' // Set via NUXT_OAUTH_BOX_CLIENT_SECRET
}
}
}
})Create .env file:
NUXT_OAUTH_BOX_CLIENT_ID=your_client_id_here
NUXT_OAUTH_BOX_CLIENT_SECRET=your_client_secret_here
NUXT_SESSION_PASSWORD=your-32-character-session-secretπ Never commit
.envto version control!
your-nuxt-app/
βββ nuxt.config.ts
βββ .env
βββ shared/
β βββ types/
β βββ box.ts # TypeScript types
βββ server/
β βββ routes/
β β βββ auth/
β β βββ box.get.ts # OAuth: readonly scope
β β βββ box-upgrade.get.ts # OAuth: readwrite scope
β βββ api/
β βββ box/
β βββ refresh.post.ts # Token refresh
β βββ upload.post.ts # File upload
β βββ folders/
β βββ [id].get.ts # List folder contents
βββ app/
βββ pages/
βββ index.vue # Login page
βββ upload.vue # Upload page with scope upgrade
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
devtools: { enabled: true },
modules: ['nuxt-auth-utils'],
runtimeConfig: {
oauth: {
box: {
clientId: '',
clientSecret: ''
}
}
}
})export interface BoxUser {
type: 'user'
id: string
name: string
login: string
created_at: string
modified_at: string
language: string
timezone: string
space_amount: number
space_used: number
max_upload_size: number
status: 'active' | 'inactive' | 'cannot_delete_edit' | 'cannot_delete_edit_upload'
job_title?: string
phone?: string
address?: string
avatar_url?: string
}
export interface BoxTokens {
access_token: string
refresh_token: string
expires_in: number
token_type: 'bearer'
restricted_to?: Array<{
scope: string
object?: {
type: string
id: string
}
}>
}
// OAuth scope levels for progressive consent
export type BoxScope = 'root_readonly' | 'root_readwrite'// OAuth handler for Box - readonly scope (basic login)
export default defineOAuthBoxEventHandler({
config: {
scope: ['root_readonly']
},
async onSuccess(event, { user, tokens }) {
await setUserSession(event, {
user,
tokens,
scope: 'root_readonly'
})
return sendRedirect(event, '/')
},
onError(event, error) {
console.error('Box OAuth error:', error)
return sendRedirect(event, '/?error=box-auth')
},
})// OAuth handler for Box - readwrite scope (upgrade for uploads)
// This is a separate endpoint to work with Box's redirect URI registration
export default defineOAuthBoxEventHandler({
config: {
scope: ['root_readwrite']
},
async onSuccess(event, { user, tokens }) {
await setUserSession(event, {
user,
tokens,
scope: 'root_readwrite'
})
return sendRedirect(event, '/upload')
},
onError(event, error) {
console.error('Box OAuth error:', error)
return sendRedirect(event, '/upload?error=permission-denied')
},
})export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
const tokens = session.tokens as BoxTokens | undefined
if (!tokens?.refresh_token) {
throw createError({
statusCode: 401,
message: 'No refresh token available'
})
}
const config = useRuntimeConfig(event)
// Request new tokens from Box
const newTokens = await $fetch<BoxTokens>('https://api.box.com/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: config.oauth.box.clientId,
client_secret: config.oauth.box.clientSecret
})
})
// Update session with new tokens, preserving scope
await setUserSession(event, {
user: session.user,
tokens: newTokens,
scope: session.scope || 'root_readonly', // Preserve scope
loggedInAt: session.loggedInAt
})
return { success: true }
})export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
const tokens = session.tokens as BoxTokens | undefined
if (!tokens?.access_token) {
throw createError({ statusCode: 401, message: 'Not authenticated with Box' })
}
// Check for write scope - required for uploads
const scope = session.scope as string | undefined
if (scope !== 'root_readwrite') {
throw createError({
statusCode: 403,
message: 'Upload requires write permissions. Please grant upload access.',
data: { requiresScope: 'root_readwrite', currentScope: scope || 'root_readonly' }
})
}
// Read multipart form data from client
const formData = await readMultipartFormData(event)
if (!formData || formData.length === 0) {
throw createError({ statusCode: 400, message: 'No file provided' })
}
const file = formData.find(f => f.name === 'file')
const parentId = formData.find(f => f.name === 'parent_id')?.data.toString() || '0'
if (!file || !file.data) {
throw createError({ statusCode: 400, message: 'No file data' })
}
// Box upload API uses multipart with 'attributes' JSON + file
const attributes = JSON.stringify({
name: file.filename || 'uploaded-file',
parent: { id: parentId },
})
const uploadForm = new FormData()
uploadForm.append('attributes', attributes)
uploadForm.append('file', new Blob([file.data]), file.filename || 'file')
const result = await $fetch('https://upload.box.com/api/2.0/files/content', {
method: 'POST',
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
body: uploadForm,
})
return result
})export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
const tokens = session.tokens as BoxTokens | undefined
if (!tokens?.access_token) {
throw createError({ statusCode: 401, message: 'Not authenticated with Box' })
}
const folderId = getRouterParam(event, 'id') || '0'
const items = await $fetch(`https://api.box.com/2.0/folders/${folderId}/items`, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
query: {
fields: 'id,type,name',
limit: 100,
},
})
return items
})<script setup>
const { loggedIn, user, session, fetch, clear, openInPopup } = useUserSession()
async function refreshTokens() {
await $fetch('/api/box/refresh', { method: 'POST' })
await fetch() // Reload session with new tokens
}
</script>
<template>
<div v-if="loggedIn">
<h1>Welcome {{ user.name }}!</h1>
<p>Logged in as {{ user.login }}</p>
<p>Current scope: {{ session.scope }}</p>
<nav>
<NuxtLink to="/upload">Upload Files</NuxtLink>
</nav>
<button @click="refreshTokens">Refresh Tokens</button>
<button @click="clear">Logout</button>
</div>
<div v-else>
<h1>Welcome</h1>
<p>Please log in to continue.</p>
<a href="/auth/box">Login with Box</a>
<!-- Or use popup -->
<button @click="openInPopup('/auth/box')">Login with Box (Popup)</button>
</div>
</template><template>
<div class="upload-page">
<h1>Upload to Box</h1>
<!-- Not logged in -->
<div v-if="!loggedIn" class="auth-prompt">
<p>Please log in to upload files to Box.</p>
<a href="/auth/box" class="btn">Login with Box</a>
</div>
<!-- Logged in but needs write permission -->
<div v-else-if="!hasWriteScope" class="upgrade-prompt">
<h2>π Upload requires additional permissions</h2>
<p>You're currently logged in with read-only access. To upload files, we need your permission to write to your Box account.</p>
<a :href="upgradeUrl" class="btn btn-upgrade">
π Grant upload permission
</a>
<p class="note">You'll be redirected to Box to approve write access.</p>
</div>
<!-- Has write permission - show upload form -->
<template v-else>
<!-- Permission denied error from OAuth -->
<div v-if="permissionDenied" class="warning">
β οΈ Permission was not granted. Upload requires write access to your Box account.
</div>
<form @submit.prevent="uploadFile" class="upload-form">
<div class="form-group">
<label for="file">Select File</label>
<input type="file" id="file" ref="fileInput" required />
</div>
<div class="form-group">
<label for="folderId">Destination Folder ID</label>
<input type="text" id="folderId" v-model="folderId" placeholder="0 = root folder" />
<small>Leave as 0 to upload to root, or enter a specific folder ID</small>
</div>
<button type="submit" :disabled="uploading">
{{ uploading ? 'Uploading...' : 'Upload to Box' }}
</button>
</form>
<div v-if="result" class="success">
β
Uploaded: <strong>{{ result.entries?.[0]?.name }}</strong>
<br />File ID: {{ result.entries?.[0]?.id }}
</div>
<div v-if="error" class="error">β {{ error }}</div>
</template>
</div>
</template>
<script setup>
const route = useRoute()
const { loggedIn, session } = useUserSession()
// Check if user has write scope
const hasWriteScope = computed(() => session.value?.scope === 'root_readwrite')
// Build upgrade URL - uses separate /auth/box-upgrade endpoint
const upgradeUrl = '/auth/box-upgrade'
// Check for permission denied error from OAuth redirect
const permissionDenied = computed(() => route.query.error === 'permission-denied')
const fileInput = ref(null)
const folderId = ref('0')
const uploading = ref(false)
const result = ref(null)
const error = ref(null)
async function uploadFile() {
const file = fileInput.value?.files?.[0]
if (!file) return
uploading.value = true
error.value = null
result.value = null
const formData = new FormData()
formData.append('file', file)
formData.append('parent_id', folderId.value || '0')
try {
result.value = await $fetch('/api/box/upload', {
method: 'POST',
body: formData,
})
} catch (e) {
error.value = e.data?.message || e.message
} finally {
uploading.value = false
}
}
</script>
<style scoped>
.upload-page {
max-width: 500px;
margin: 2rem auto;
padding: 1rem;
}
.auth-prompt,
.upgrade-prompt {
text-align: center;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #0061d5;
color: white;
border: none;
border-radius: 4px;
text-decoration: none;
}
.btn-upgrade {
background: #28a745;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.success {
margin-top: 1rem;
padding: 1rem;
background: #d4edda;
border-radius: 4px;
}
.warning {
margin-bottom: 1rem;
padding: 1rem;
background: #fff3cd;
border-radius: 4px;
}
.error {
margin-top: 1rem;
padding: 1rem;
background: #f8d7da;
border-radius: 4px;
}
</style>βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INITIAL LOGIN β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1. User visits app β
β 2. Clicks "Login with Box" β
β 3. Redirected to /auth/box β
β 4. Box shows: "Read access to files" consent β
β 5. User approves β Session created with scope=root_readonlyβ
β 6. Redirected to / β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β UPLOAD PAGE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1. User visits /upload β
β 2. Page checks: session.scope === 'root_readwrite'? β
β β
β NO β Shows "Grant upload permission" button β
β β β
β βΌ β
β 3. User clicks button β /auth/box-upgrade β
β 4. Box shows: "Read AND WRITE access" consent β
β 5. User approves β Session updated with scope=root_readwriteβ
β 6. Redirected back to /upload β
β β
β YES β Shows upload form β
β 7. User uploads file successfully β
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Cause: The root_readwrite scope is not enabled in your Box app.
Fix:
- Go to Box Developer Console β Your App β Configuration
- Under Application Scopes, enable "Read and write all files and folders stored in Box"
- Save changes
Cause: The redirect URI is not registered in Box.
Fix:
- Go to Box Developer Console β Your App β Configuration
- Under OAuth 2.0 Redirect URI, add both:
http://localhost:3000/auth/boxhttp://localhost:3000/auth/box-upgrade
- Save changes
Cause: User has root_readonly scope but is trying to upload.
Fix: The upload page should detect this and show the upgrade prompt. If not:
- Check
session.scopeis being stored correctly in OAuth handlers - Check
hasWriteScopecomputed property in upload.vue - User should click "Grant upload permission" to upgrade scope
Box access tokens expire after ~60 minutes. Use the /api/box/refresh endpoint to get new tokens:
await $fetch('/api/box/refresh', { method: 'POST' })The refresh preserves the current scope.
| Endpoint | Description |
|---|---|
| OAuth 2.0 | Authentication flow |
| Scopes | Available permission scopes |
| Upload File | Upload file to Box |
| Get Folder Items | List folder contents |
| Refresh Token | Refresh access token |
MIT