Skip to content

Instantly share code, notes, and snippets.

@robert-hoffmann
Created February 2, 2026 11:37
Show Gist options
  • Select an option

  • Save robert-hoffmann/f673b3df89f63ec6958568ddd3b5f8b1 to your computer and use it in GitHub Desktop.

Select an option

Save robert-hoffmann/f673b3df89f63ec6958568ddd3b5f8b1 to your computer and use it in GitHub Desktop.

Box OAuth Integration for Nuxt 4

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.

Table of Contents

  1. Overview
  2. Prerequisites
  3. Box Developer Console Setup
  4. Project Setup
  5. File Structure
  6. Configuration
  7. Type Definitions
  8. OAuth Handlers
  9. API Endpoints
  10. Frontend Pages
  11. User Flow
  12. Troubleshooting

Overview

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

Prerequisites

  • Node.js 18+
  • pnpm (recommended) or npm
  • A Box account (free or enterprise)
  • A Box Developer application

Box Developer Console Setup

1. Create a Box Application

  1. Go to Box Developer Console
  2. Click "Create New App"
  3. Select "Custom App"
  4. Choose "User Authentication (OAuth 2.0)"
  5. Name your app and click "Create App"

2. Configure Application Scopes

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 enable root_readwrite scope here, requesting it via OAuth will fail with invalid_scope error.

3. Configure OAuth 2.0 Settings

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

4. Get Your Credentials

Note down from the Configuration tab:

  • Client ID
  • Client Secret

Project Setup

1. Create Nuxt Project

npx nuxi@latest init my-box-app
cd my-box-app

2. Install Dependencies

pnpm add nuxt-auth-utils

3. Configure nuxt.config.ts

// 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
      }
    }
  }
})

4. Environment Variables

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 .env to version control!


File Structure

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

Configuration

nuxt.config.ts (Complete)

export default defineNuxtConfig({
  compatibilityDate: '2025-01-01',
  devtools: { enabled: true },
  
  modules: ['nuxt-auth-utils'],
  
  runtimeConfig: {
    oauth: {
      box: {
        clientId: '',
        clientSecret: ''
      }
    }
  }
})

Type Definitions

shared/types/box.ts

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 Handlers

server/routes/auth/box.get.ts (Read-Only Login)

// 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')
  },
})

server/routes/auth/box-upgrade.get.ts (Read-Write Upgrade)

// 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')
  },
})

API Endpoints

server/api/box/refresh.post.ts (Token Refresh)

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 }
})

server/api/box/upload.post.ts (File Upload)

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
})

server/api/box/folders/[id].get.ts (List Folder Contents)

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
})

Frontend Pages

app/pages/index.vue (Login Page)

<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>

app/pages/upload.vue (Upload with Progressive Consent)

<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>

User Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     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 βœ…                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Troubleshooting

Error: invalid_scope: Unsupported scope was requested

Cause: The root_readwrite scope is not enabled in your Box app.

Fix:

  1. Go to Box Developer Console β†’ Your App β†’ Configuration
  2. Under Application Scopes, enable "Read and write all files and folders stored in Box"
  3. Save changes

Error: redirect_uri_mismatch

Cause: The redirect URI is not registered in Box.

Fix:

  1. Go to Box Developer Console β†’ Your App β†’ Configuration
  2. Under OAuth 2.0 Redirect URI, add both:
    • http://localhost:3000/auth/box
    • http://localhost:3000/auth/box-upgrade
  3. Save changes

Error: 403 Forbidden when uploading

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:

  1. Check session.scope is being stored correctly in OAuth handlers
  2. Check hasWriteScope computed property in upload.vue
  3. User should click "Grant upload permission" to upgrade scope

Tokens Expired

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.


Box API Reference

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

License

MIT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment