Connecting HubSpot to React: A Guide to OAuth 2.0 Implementation

Connecting HubSpot to React: A Guide to OAuth 2.0 Implementation

The Problem

Building a React application that needs to access HubSpot data creates an immediate authentication challenge. You can’t hardcode API keys in client-side code—they’ll be exposed to anyone who views your source. You also can’t ask users to manually generate and paste their own private API keys; that’s both a security risk and terrible UX. What you need is OAuth 2.0, which allows users to grant your app permission to access their HubSpot account without ever sharing credentials. However, OAuth requires a secure server-side component to handle the token exchange, and coordinating this flow between your React frontend, Node.js backend, and HubSpot’s API involves multiple redirects, state management, and token storage. Get it wrong, and you’ll face CSRF attacks, token leaks, or frustrated users stuck in authentication loops. This guide provides working, copy-paste ready code to implement the complete OAuth flow correctly.

Tech Stack & Prerequisites

  • Node.js v18+ with npm or yarn
  • React 18+ (created with Vite or Create React App)
  • Express.js 4.18+ for the backend server
  • HubSpot Developer Account (free at developers.hubspot.com)
  • HubSpot App created in your developer account with OAuth configured
  • axios for HTTP requests
  • dotenv for environment variable management
  • Basic understanding of React hooks and Express routing

Required HubSpot Setup:

  • A HubSpot app created at developers.hubspot.com/apps
  • OAuth redirect URL configured (e.g., http://localhost:3001/oauth/callback)
  • Client ID and Client Secret from your app settings
  • Required scopes selected (e.g., crm.objects.contacts.read)

Step-by-Step Implementation

Step 1: Setup

First, create the project structure with separate frontend and backend directories.

Initialize the backend:

bash
mkdir hubspot-oauth-integration
cd hubspot-oauth-integration
mkdir backend frontend
cd backend
npm init -y
npm install express axios dotenv cors

Initialize the React frontend:

bash
cd ../frontend
npm create vite@latest . -- --template react
npm install axios
```

Your project structure should look like:
```
hubspot-oauth-integration/
├── backend/
│   ├── server.js
│   ├── .env
│   └── package.json
└── frontend/
    ├── src/
    │   ├── App.jsx
    │   └── components/
    │       └── HubSpotConnect.jsx
    └── package.json

Step 2: Configuration

backend/.env — Store your HubSpot credentials securely:

env
HUBSPOT_CLIENT_ID=your_client_id_here
HUBSPOT_CLIENT_SECRET=your_client_secret_here
REDIRECT_URI=http://localhost:3001/oauth/callback
FRONTEND_URL=http://localhost:5173
PORT=3001

Important: Add .env to your .gitignore immediately:

bash
echo ".env" >> .gitignore

backend/server.js — Create the Express server with OAuth endpoints:

javascript
import express from 'express';
import axios from 'axios';
import dotenv from 'dotenv';
import cors from 'cors';
import crypto from 'crypto';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3001;

// Store state tokens temporarily (use Redis in production)
const stateStore = new Map();

// CORS configuration
app.use(cors({
  origin: process.env.FRONTEND_URL,
  credentials: true
}));

app.use(express.json());

// Generate authorization URL
app.get('/oauth/authorize', (req, res) => {
  // Generate random state for CSRF protection
  const state = crypto.randomBytes(16).toString('hex');
  stateStore.set(state, Date.now());
  
  const authUrl = `https://app.hubspot.com/oauth/authorize?` +
    `client_id=${process.env.HUBSPOT_CLIENT_ID}` +
    `&redirect_uri=${encodeURIComponent(process.env.REDIRECT_URI)}` +
    `&scope=crm.objects.contacts.read crm.objects.contacts.write` +
    `&state=${state}`;
  
  res.json({ authUrl, state });
});

// OAuth callback - exchange code for tokens
app.get('/oauth/callback', async (req, res) => {
  const { code, state } = req.query;
  
  // Verify state to prevent CSRF
  if (!state || !stateStore.has(state)) {
    return res.redirect(`${process.env.FRONTEND_URL}/error?message=invalid_state`);
  }
  
  // Clean up used state token
  stateStore.delete(state);
  
  if (!code) {
    return res.redirect(`${process.env.FRONTEND_URL}/error?message=no_code`);
  }
  
  try {
    // Exchange authorization code for access token
    const tokenResponse = await axios.post(
      'https://api.hubapi.com/oauth/v1/token',
      new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: process.env.HUBSPOT_CLIENT_ID,
        client_secret: process.env.HUBSPOT_CLIENT_SECRET,
        redirect_uri: process.env.REDIRECT_URI,
        code: code
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      }
    );
    
    const { access_token, refresh_token, expires_in } = tokenResponse.data;
    
    // In production, store tokens securely in a database associated with user
    // For this tutorial, we'll pass them back to frontend (NOT RECOMMENDED for production)
    const tokens = encodeURIComponent(JSON.stringify({
      accessToken: access_token,
      refreshToken: refresh_token,
      expiresIn: expires_in
    }));
    
    res.redirect(`${process.env.FRONTEND_URL}/success?tokens=${tokens}`);
    
  } catch (error) {
    console.error('Token exchange error:', error.response?.data || error.message);
    res.redirect(`${process.env.FRONTEND_URL}/error?message=token_exchange_failed`);
  }
});

// Refresh access token
app.post('/oauth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(400).json({ error: 'Refresh token required' });
  }
  
  try {
    const tokenResponse = await axios.post(
      'https://api.hubapi.com/oauth/v1/token',
      new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: process.env.HUBSPOT_CLIENT_ID,
        client_secret: process.env.HUBSPOT_CLIENT_SECRET,
        refresh_token: refreshToken
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      }
    );
    
    res.json({
      accessToken: tokenResponse.data.access_token,
      refreshToken: tokenResponse.data.refresh_token,
      expiresIn: tokenResponse.data.expires_in
    });
    
  } catch (error) {
    console.error('Token refresh error:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to refresh token' });
  }
});

// Proxy endpoint to fetch HubSpot contacts
app.get('/api/contacts', async (req, res) => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid authorization header' });
  }
  
  const accessToken = authHeader.split(' ')[1];
  
  try {
    const response = await axios.get(
      'https://api.hubapi.com/crm/v3/objects/contacts',
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`
        },
        params: {
          limit: 10,
          properties: 'firstname,lastname,email'
        }
      }
    );
    
    res.json(response.data);
    
  } catch (error) {
    console.error('Contacts fetch error:', error.response?.data || error.message);
    res.status(error.response?.status || 500).json({ 
      error: error.response?.data || 'Failed to fetch contacts' 
    });
  }
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Add “type”: “module” to backend/package.json to enable ES6 imports:

json
{
  "name": "backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  }
}

Step 3: Core Logic

frontend/src/components/HubSpotConnect.jsx — React component for OAuth flow:

jsx
import { useState, useEffect } from 'react';
import axios from 'axios';

const API_BASE_URL = 'http://localhost:3001';

function HubSpotConnect() {
  const [isConnected, setIsConnected] = useState(false);
  const [contacts, setContacts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [tokens, setTokens] = useState(null);

  // Check for OAuth callback on mount
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const tokensParam = params.get('tokens');
    const errorParam = params.get('message');
    
    if (tokensParam) {
      try {
        const parsedTokens = JSON.parse(decodeURIComponent(tokensParam));
        setTokens(parsedTokens);
        setIsConnected(true);
        
        // Store tokens in localStorage (in production, use httpOnly cookies)
        localStorage.setItem('hubspot_tokens', JSON.stringify(parsedTokens));
        
        // Clean up URL
        window.history.replaceState({}, document.title, window.location.pathname);
      } catch (err) {
        setError('Failed to parse authentication tokens');
      }
    } else if (errorParam) {
      setError(`Authentication failed: ${errorParam}`);
    }
    
    // Check for existing tokens
    const storedTokens = localStorage.getItem('hubspot_tokens');
    if (storedTokens) {
      setTokens(JSON.parse(storedTokens));
      setIsConnected(true);
    }
  }, []);

  // Initiate OAuth flow
  const connectToHubSpot = async () => {
    try {
      setLoading(true);
      setError(null);
      
      const response = await axios.get(`${API_BASE_URL}/oauth/authorize`);
      const { authUrl } = response.data;
      
      // Redirect to HubSpot authorization page
      window.location.href = authUrl;
      
    } catch (err) {
      setError('Failed to initiate HubSpot connection');
      setLoading(false);
    }
  };

  // Fetch contacts from HubSpot
  const fetchContacts = async () => {
    if (!tokens?.accessToken) {
      setError('No access token available');
      return;
    }
    
    try {
      setLoading(true);
      setError(null);
      
      const response = await axios.get(`${API_BASE_URL}/api/contacts`, {
        headers: {
          'Authorization': `Bearer ${tokens.accessToken}`
        }
      });
      
      setContacts(response.data.results || []);
      
    } catch (err) {
      // If token expired, try refreshing
      if (err.response?.status === 401 && tokens?.refreshToken) {
        await refreshAccessToken();
        return fetchContacts(); // Retry after refresh
      }
      
      setError(err.response?.data?.error || 'Failed to fetch contacts');
    } finally {
      setLoading(false);
    }
  };

  // Refresh expired access token
  const refreshAccessToken = async () => {
    try {
      const response = await axios.post(`${API_BASE_URL}/oauth/refresh`, {
        refreshToken: tokens.refreshToken
      });
      
      const newTokens = {
        ...tokens,
        accessToken: response.data.accessToken,
        refreshToken: response.data.refreshToken || tokens.refreshToken
      };
      
      setTokens(newTokens);
      localStorage.setItem('hubspot_tokens', JSON.stringify(newTokens));
      
    } catch (err) {
      setError('Failed to refresh access token. Please reconnect.');
      disconnect();
    }
  };

  // Disconnect and clear tokens
  const disconnect = () => {
    setIsConnected(false);
    setTokens(null);
    setContacts([]);
    localStorage.removeItem('hubspot_tokens');
  };

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h1>HubSpot OAuth Integration</h1>
      
      {error && (
        <div style={{ 
          padding: '10px', 
          marginBottom: '20px', 
          backgroundColor: '#fee', 
          color: '#c33',
          borderRadius: '4px' 
        }}>
          {error}
        </div>
      )}
      
      {!isConnected ? (
        <div>
          <p>Connect your HubSpot account to access your contacts.</p>
          <button 
            onClick={connectToHubSpot} 
            disabled={loading}
            style={{
              padding: '10px 20px',
              fontSize: '16px',
              backgroundColor: '#ff7a59',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: loading ? 'not-allowed' : 'pointer'
            }}
          >
            {loading ? 'Connecting...' : 'Connect to HubSpot'}
          </button>
        </div>
      ) : (
        <div>
          <div style={{ marginBottom: '20px' }}>
            <span style={{ color: 'green', marginRight: '10px' }}>✓ Connected</span>
            <button 
              onClick={disconnect}
              style={{
                padding: '5px 10px',
                fontSize: '14px',
                backgroundColor: '#666',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}
            >
              Disconnect
            </button>
          </div>
          
          <button 
            onClick={fetchContacts} 
            disabled={loading}
            style={{
              padding: '10px 20px',
              fontSize: '16px',
              backgroundColor: '#0091ae',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: loading ? 'not-allowed' : 'pointer',
              marginBottom: '20px'
            }}
          >
            {loading ? 'Loading...' : 'Fetch Contacts'}
          </button>
          
          {contacts.length > 0 && (
            <div>
              <h2>Contacts ({contacts.length})</h2>
              <ul style={{ listStyle: 'none', padding: 0 }}>
                {contacts.map(contact => (
                  <li 
                    key={contact.id}
                    style={{
                      padding: '10px',
                      marginBottom: '10px',
                      backgroundColor: '#f5f5f5',
                      borderRadius: '4px'
                    }}
                  >
                    <strong>
                      {contact.properties.firstname} {contact.properties.lastname}
                    </strong>
                    <br />
                    <span style={{ color: '#666' }}>
                      {contact.properties.email}
                    </span>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

export default HubSpotConnect;

frontend/src/App.jsx — Update the main App component:

jsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import HubSpotConnect from './components/HubSpotConnect';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<HubSpotConnect />} />
        <Route path="/success" element={<HubSpotConnect />} />
        <Route path="/error" element={<HubSpotConnect />} />
      </Routes>
    </Router>
  );
}

export default App;

Install React Router in the frontend:

bash
cd frontend
npm install react-router-dom

Step 4: Testing

Start the backend server:

bash
cd backend
npm run dev

Start the React frontend (in a new terminal):

bash
cd frontend
npm run dev

Testing Checklist:

  1. Visit http://localhost:5173 in your browser
  2. Click “Connect to HubSpot” button
  3. Verify redirect to HubSpot’s authorization page (app.hubspot.com)
  4. Grant permissions when prompted
  5. Confirm redirect back to your app with “Connected” status
  6. Click “Fetch Contacts” to retrieve data
  7. Inspect network tab to see OAuth token in Authorization header

Using curl to test the backend directly:

bash
# Test authorization URL generation
curl http://localhost:3001/oauth/authorize

# Test contacts endpoint (replace YOUR_ACCESS_TOKEN)
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     http://localhost:3001/api/contacts
```

**Check for these success indicators:**
- No CORS errors in browser console
- Access token received after OAuth callback
- Contacts display with names and emails
- Token refresh works when access token expires (after ~6 hours)

**[DIAGRAM SUGGESTION: Flow diagram showing: React App → Backend /authorize → HubSpot OAuth → Callback → Token Exchange → Access Token → API Request]**

## Common Errors & Troubleshooting

### Error 1: "redirect_uri_mismatch"

**Problem:** HubSpot returns an error saying the redirect URI doesn't match.

**Solution:** The redirect URI in your HubSpot app settings must **exactly** match `REDIRECT_URI` in your `.env` file. Check for:
- Trailing slashes (use none)
- HTTP vs HTTPS (match your environment)
- Port numbers (include :3001)
- Case sensitivity

Go to developers.hubspot.com → Your App → Auth → Redirect URLs and add:
```
http://localhost:3001/oauth/callback

Error 2: CORS Policy Error in Browser Console

Problem: Browser blocks requests with message like “blocked by CORS policy”.

Solution: The backend must send proper CORS headers. Verify:

javascript
// In server.js, ensure this is BEFORE route definitions
app.use(cors({
  origin: process.env.FRONTEND_URL, // Must match React dev server
  credentials: true
}));

If you changed your frontend port, update FRONTEND_URL in .env and restart the backend.

Error 3: “Invalid or Expired Token” When Fetching Contacts

Problem: Token works initially but stops working after some time.

Solution: Access tokens expire after 6 hours. Implement automatic refresh:

javascript
// In HubSpotConnect.jsx, the fetchContacts function already handles this:
if (err.response?.status === 401 && tokens?.refreshToken) {
  await refreshAccessToken();
  return fetchContacts(); // Retry after refresh
}

Make sure you’re storing the refresh_token returned during the initial OAuth exchange. Refresh tokens are valid for extended periods and allow you to get new access tokens without user interaction.

Security Checklist

Critical security practices for production deployments:

  • Never expose tokens in frontend code — Store access/refresh tokens server-side in a database, not in React state or localStorage. Use httpOnly cookies or sessions for token management.
  • Use HTTPS in production — Update all URLs to https:// and ensure your redirect URI uses SSL. HubSpot will reject non-HTTPS redirects in production.
  • Validate the state parameter — The code includes CSRF protection via state tokens. In production, store state in Redis with expiration (5 minutes) instead of an in-memory Map.
  • Rotate and secure environment variables — Use services like AWS Secrets Manager or HashiCorp Vault. Never commit .env files to Git.
  • Implement rate limiting — Add express-rate-limit to prevent abuse:
javascript
  import rateLimit from 'express-rate-limit';
  
  const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100 // limit each IP to 100 requests per windowMs
  });
  
  app.use('/api/', limiter);
  • Scope principle of least privilege — Only request HubSpot scopes your app actually needs. Remove unused scopes like crm.objects.contacts.write if you’re only reading data.
  • Handle token storage carefully — In production, encrypt tokens at rest and associate them with authenticated user sessions in your database.
  • Set up webhook signature verification — If you add HubSpot webhooks later, verify signatures to prevent forged requests.

Leave a Comment

Your email address will not be published. Required fields are marked *