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:
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:
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:
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:
echo ".env" >> .gitignore
backend/server.js — Create the Express server with OAuth endpoints:
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:
{
"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:
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:
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:
cd frontend
npm install react-router-dom
Step 4: Testing
Start the backend server:
cd backend
npm run dev
Start the React frontend (in a new terminal):
cd frontend
npm run dev
Testing Checklist:
- Visit
http://localhost:5173in your browser - Click “Connect to HubSpot” button
- Verify redirect to HubSpot’s authorization page (app.hubspot.com)
- Grant permissions when prompted
- Confirm redirect back to your app with “Connected” status
- Click “Fetch Contacts” to retrieve data
- Inspect network tab to see OAuth token in Authorization header
Using curl to test the backend directly:
# 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:
// 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:
// 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
.envfiles to Git. - Implement rate limiting — Add express-rate-limit to prevent abuse:
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.writeif 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.

Qasim is a Software Engineer with a focus on backend infrastructure and secure API connectivity. He specializes in breaking down complex integration challenges into clear, step-by-step technical blueprints for developers and engineers. Outside of the terminal, Qasim is passionate about technical efficiency and staying ahead of emerging software trends.

