OAuth 2.0 in Production: Building Secure Integrations with Xero, QuickBooks, and Microsoft
Adam Dugan • January 30, 2026
Most OAuth 2.0 tutorials show you how to get an access token and stop there. That's maybe 20% of what you need for production. The other 80% is handling token refresh, encrypting secrets, multi-tenant isolation, CSRF protection, and debugging when things go wrong.
I've built OAuth integrations for BalancingIQ (Xero and QuickBooks financial data) and SOA Assist Pro (Microsoft Outlook for appointment scheduling and email) and more. Here's everything I learned about making OAuth work reliably in production.
The Problem: OAuth Tutorials Skip the Hard Parts
A typical OAuth tutorial teaches you:
- Redirect user to provider's authorization URL
- User grants permission
- Provider redirects back with an authorization code
- Exchange code for access token
- Make API calls with access token
But this leaves out critical production concerns:
- CSRF attacks: How do you verify the callback is legitimate?
- Authorization code interception: What if someone steals the code?
- Token storage: Where do you store refresh tokens securely?
- Token refresh: Access tokens expire in 30-60 minutes. How do you handle refresh automatically?
- Multi-tenancy: How do you isolate tokens across different users/businesses?
- Error handling: What happens when refresh fails? When the user revokes access?
Let's solve all of these with a production-ready architecture.
Architecture Overview
Here's the full stack for BalancingIQ's OAuth implementation:
- Frontend: Next.js App Router (React Server Components + API Routes)
- Backend: AWS Lambda functions (Python)
- Storage: DynamoDB for token storage
- Encryption: AWS KMS for encrypting refresh tokens
- Auth: AWS Cognito for user authentication
- Providers: Xero, QuickBooks Online, Microsoft Outlook
The flow spans across Next.js API routes (for OAuth redirects), Lambda functions (for business logic), DynamoDB (for persistence), and KMS (for security). Let's walk through each step.
Step 1: Authorization Initiation with PKCE
When a user clicks "Connect Xero" in BalancingIQ, we need to redirect them to Xero's authorization page. But first, we implement PKCE (Proof Key for Code Exchange) to prevent authorization code interception attacks.
What is PKCE?
PKCE is an extension to OAuth 2.0 that protects against malicious apps intercepting authorization codes. It works by:
- Generating a random
code_verifier(a secret) - Hashing it to create a
code_challenge - Sending the challenge (not the verifier) to the authorization server
- Proving you have the verifier when exchanging the code for tokens
This means even if someone intercepts your authorization code, they can't exchange it for tokens without the original verifier.
Implementation: Next.js API Route
Here's the API route that initiates OAuth:
import { randomBytes, createHash } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
function base64url(buf: Buffer) {
return buf.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get('businessId');
// Generate PKCE parameters
const state = base64url(randomBytes(16)); // CSRF protection
const codeVerifier = base64url(randomBytes(32)); // PKCE secret
const codeChallenge = base64url(
createHash('sha256').update(codeVerifier).digest()
);
// Store PKCE data in httpOnly cookie (expires in 10 minutes)
const response = NextResponse.redirect(authorizationUrl);
response.cookies.set({
name: 'xero_pkce',
value: JSON.stringify({ state, codeVerifier, businessId }),
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 600, // 10 minutes
domain: '.mybalancingiq.com',
});
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.XERO_CLIENT_ID!,
redirect_uri: process.env.XERO_REDIRECT_URI!,
scope: 'openid profile email accounting.transactions',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const authUrl = `https://login.xero.com/identity/connect/authorize?${params}`;
return NextResponse.redirect(authUrl);
}Key Security Decisions
- httpOnly cookie: Prevents JavaScript from accessing PKCE data (XSS protection)
- secure: true: Only sent over HTTPS
- sameSite: 'lax': Prevents CSRF while allowing OAuth callbacks
- Short expiry (600s): Limits attack window
- Random state: Prevents CSRF attacks by ensuring callbacks are from our initiated flow
Important: Don't use sameSite: 'strict'! It will block OAuth callbacks because they come from external domains (Xero, QuickBooks, etc.). Use sameSite: 'lax' instead.
Step 2: Authorization Callback and Token Exchange
After the user authorizes your app, the provider redirects back to your callback URL with an authorization code. Now you need to:
- Validate the state parameter (CSRF protection)
- Exchange the authorization code for tokens using PKCE
- Retrieve the organization/tenant information
- Store tokens securely
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state');
// 1. Retrieve PKCE data from cookie
const pkceCookie = request.cookies.get('xero_pkce');
if (!pkceCookie) {
return NextResponse.redirect(
`${process.env.AMPLIFY_APP_ORIGIN}/error?message=pkce_missing`
);
}
const { state: savedState, codeVerifier, businessId } =
JSON.parse(pkceCookie.value);
// 2. Validate state (CSRF protection)
if (state !== savedState) {
console.error('State mismatch - possible CSRF attack');
return NextResponse.redirect(
`${process.env.AMPLIFY_APP_ORIGIN}/error?message=invalid_state`
);
}
// 3. Exchange authorization code for tokens
const tokenResponse = await fetch(
'https://identity.xero.com/connect/token',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(
`${XERO_CLIENT_ID}:${XERO_CLIENT_SECRET}`
).toString('base64')}`
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code!,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier // PKCE proof
})
}
);
const tokens = await tokenResponse.json();
// 4. Get tenant/organization info
const connectionResponse = await fetch(
'https://api.xero.com/connections',
{
headers: {
'Authorization': `Bearer ${tokens.access_token}`,
'Content-Type': 'application/json'
}
}
);
const connections = await connectionResponse.json();
const tenant = connections[0]; // First connected organization
// 5. Store tokens securely (invoke Lambda via GraphQL)
const integrationData = {
businessId,
provider: 'xero',
tenantId: tenant.tenantId,
orgName: tenant.tenantName,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scopes: tokens.scope,
};
await storeTokens(integrationData);
// 6. Clear PKCE cookie
const response = NextResponse.redirect(
`${process.env.AMPLIFY_APP_ORIGIN}/businessprofile?connected=xero`
);
response.cookies.delete('xero_pkce');
return response;
}Why Basic Auth for Token Exchange?
Notice the Authorization: Basic header? This is standard OAuth 2.0. You send your client ID and secret as Base64-encoded Basic Auth credentials:
const credentials = Buffer.from(`${clientId}:${clientSecret}`)
.toString('base64');
// Becomes: Authorization: Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=This authenticates your application to the provider. Never expose your client secret in frontend code, always do token exchange server-side.
Step 3: Secure Token Storage with KMS Encryption
Now we have tokens. Where do we store them? Never store refresh tokens in plain text. If your database is compromised, attackers get permanent access to user data.
BalancingIQ uses AWS KMS (Key Management Service) to encrypt refresh tokens before storing them in DynamoDB.
Lambda Function: Token Storage
import boto3
import base64
from datetime import datetime
kms_client = boto3.client('kms')
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['BOOKKEEPING_INTEGRATIONS_TABLE'])
def handler(event, context):
data = json.loads(event['arguments']['data'])
business_id = data['businessId']
provider = data['provider']
tenant_id = data['tenantId']
refresh_token = data['refreshToken']
# Encrypt refresh token with KMS
encrypted_response = kms_client.encrypt(
KeyId=os.environ['APP_KMS_KEY_ID'],
Plaintext=refresh_token.encode('utf-8')
)
refresh_ciphertext = base64.b64encode(
encrypted_response['CiphertextBlob']
).decode('utf-8')
# Calculate token expiration
now = int(datetime.utcnow().timestamp())
expires_at = now + int(data['expiresIn']) - 30 # 30s buffer
# Store in DynamoDB
sort_key = f"{provider}#{tenant_id}"
table.put_item(Item={
'businessId': business_id, # Partition key
'sortKey': sort_key, # Sort key: provider#tenantId
'provider': provider,
'tenantId': tenant_id,
'orgName': data['orgName'],
'scopes': data['scopes'],
'refreshCiphertext': refresh_ciphertext, # Encrypted!
'accessToken': data['accessToken'], # Short-lived, less sensitive
'accessTokenExpiresAt': expires_at,
'createdAt': now,
'updatedAt': now,
})
return {'success': True}Multi-Tenant Data Model
The DynamoDB schema uses a composite key for multi-tenant isolation:
- Partition key:
businessId(ensures data isolation per business) - Sort key:
provider#tenantId(supports multiple integrations per business)
This means:
- Business A can connect to Xero and QuickBooks simultaneously
- Business B's data is completely isolated from Business A
- Queries are fast (DynamoDB partition-key lookups)
Why Encrypt Access Tokens Too?
In the code above, I store access tokens in plain text. They're short-lived (30-60 minutes), so the risk is lower. But for maximum security, encrypt access tokens too. It's a small performance cost for defense-in-depth.
Step 4: Automatic Token Refresh
Access tokens expire quickly. Most providers give you 30-60 minutes. When your Lambda function needs to make API calls, it must check if tokens need refreshing and handle it automatically.
Here's the pattern I use in every Lambda that calls external APIs:
import boto3
import base64
from datetime import datetime
kms_client = boto3.client('kms')
dynamodb = boto3.resource('dynamodb')
def refresh_if_needed_xero(business_id, auth_record):
"""Check if access token needs refresh, refresh if needed"""
now = int(datetime.utcnow().timestamp())
expires_at = int(auth_record.get('accessTokenExpiresAt', 0))
access_token = auth_record.get('accessToken', '')
# If token is valid for >60 seconds, use it
if now < (expires_at - 60) and access_token:
print(f"Token valid for {expires_at - now} more seconds")
return access_token, auth_record['tenantId']
print("Token expired or expiring soon, refreshing...")
# Decrypt refresh token
cipher_blob = base64.b64decode(auth_record['refreshCiphertext'])
decrypted = kms_client.decrypt(
CiphertextBlob=cipher_blob,
KeyId=os.environ['APP_KMS_KEY_ID']
)
refresh_token = decrypted['Plaintext'].decode('utf-8')
# Call token endpoint
token_response = requests.post(
'https://identity.xero.com/connect/token',
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': f'Basic {get_basic_auth_header()}'
},
data={
'grant_type': 'refresh_token',
'refresh_token': refresh_token
}
)
tokens = token_response.json()
# Update stored tokens
new_access = tokens['access_token']
new_refresh = tokens.get('refresh_token', refresh_token) # Some providers rotate
new_expires_at = now + int(tokens.get('expires_in', 1800)) - 30
save_refreshed_tokens(
business_id,
auth_record,
new_access,
new_refresh,
new_expires_at
)
return new_access, auth_record['tenantId']
def save_refreshed_tokens(business_id, auth_record, access_token,
refresh_token, expires_at):
"""Encrypt and save refreshed tokens"""
# Encrypt new refresh token
encrypted = kms_client.encrypt(
KeyId=os.environ['APP_KMS_KEY_ID'],
Plaintext=refresh_token.encode('utf-8')
)
refresh_ciphertext = base64.b64encode(encrypted['CiphertextBlob']).decode()
# Update DynamoDB
table.update_item(
Key={
'businessId': business_id,
'sortKey': auth_record['sortKey']
},
UpdateExpression="""
SET accessToken = :atk,
accessTokenExpiresAt = :exp,
refreshCiphertext = :rc,
updatedAt = :upd
""",
ExpressionAttributeValues={
':atk': access_token,
':exp': expires_at,
':rc': refresh_ciphertext,
':upd': int(datetime.utcnow().timestamp())
}
)Key Design Decisions
- 60-second buffer: Refresh 60s before expiration to account for clock skew and request time
- Refresh token rotation: Some providers (like QuickBooks) return a new refresh token. Always check and update.
- Fallback to stored refresh: If no new refresh token is returned, keep using the old one
- Atomic updates: Use DynamoDB's
UpdateExpressionto avoid race conditions
Pro tip: Always log token refresh events. When debugging OAuth issues, knowing when tokens were refreshed (and if refresh failed) is invaluable.
Step 5: Making Authenticated API Calls
Now that we have valid tokens, we can make API calls. Here's the full flow in a Lambda function that fetches financial data:
def handler(event, context):
"""Fetch financial data from Xero"""
business_id = event['arguments']['businessId']
# 1. Get stored auth record
response = table.get_item(
Key={
'businessId': business_id,
'sortKey': 'xero#' # Query for Xero integration
}
)
if 'Item' not in response:
return {'error': 'Xero not connected'}
auth_record = response['Item']
# 2. Ensure token is fresh (auto-refresh if needed)
access_token, tenant_id = refresh_if_needed_xero(business_id, auth_record)
# 3. Make API call
headers = {
'Authorization': f'Bearer {access_token}',
'Xero-Tenant-Id': tenant_id,
'Accept': 'application/json'
}
# Fetch balance sheet
balance_sheet = requests.get(
'https://api.xero.com/api.xro/2.0/Reports/BalanceSheet',
headers=headers
).json()
# Fetch profit & loss
profit_loss = requests.get(
'https://api.xero.com/api.xro/2.0/Reports/ProfitAndLoss',
headers=headers
).json()
return {
'balanceSheet': balance_sheet,
'profitLoss': profit_loss
}Notice how the business logic is clean. All the token refresh complexity is hidden inrefresh_if_needed_xero(). Every Lambda that needs OAuth just calls this function first.
Provider-Specific Gotchas
Each OAuth provider has quirks. Here are the issues I encountered:
Xero
- Tenant ID requirement: Every API call needs
Xero-Tenant-Idheader - Multiple organizations: A user can have multiple Xero organizations. Always let them choose.
- Scope format: Use spaces:
openid profile email accounting.transactions - Token expiry: 30 minutes for access tokens, 60 days for refresh tokens
QuickBooks Online
- Realm ID: Called
realmId, required for all API calls (equivalent to Xero's tenant ID) - Refresh token rotation: Always returns a new refresh token. Must update storage.
- Refresh token expiry: 100 days, but you get a new one each refresh
- API versioning: Uses
minorversionquery param for API versioning
Microsoft Outlook (SOA Assist Pro)
- Scope format: Uses full URLs:
https://graph.microsoft.com/Calendars.ReadWrite - Offline access: Must request
offline_accessscope to get refresh tokens - Token lifetime: Varies by account type (personal vs. work)
- Admin consent: Work accounts may require admin approval for certain scopes
Error Handling and Edge Cases
Production OAuth isn't just about the happy path. Here's how I handle failures:
Revoked Access
Users can revoke access in Xero/QuickBooks settings. When refresh fails withinvalid_grant, delete the stored integration and notify the user:
try:
tokens = token_response.json()
except:
if token_response.status_code == 400:
error = token_response.json()
if error.get('error') == 'invalid_grant':
# User revoked access - delete integration
table.delete_item(
Key={
'businessId': business_id,
'sortKey': auth_record['sortKey']
}
)
# Notify user via email/notification
send_notification(business_id, 'xero_disconnected')
raise Exception('Xero access revoked by user')Rate Limits
Xero and QuickBooks have rate limits. Implement exponential backoff and respectRetry-After headers:
def api_call_with_retry(url, headers, max_retries=3):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
if response.status_code == 429: # Rate limited
retry_after = int(response.headers.get('Retry-After', 60))
print(f"Rate limited, waiting {retry_after}s")
time.sleep(retry_after)
continue
if response.status_code >= 500: # Server error
wait = 2 ** attempt # Exponential backoff
print(f"Server error, retrying in {wait}s")
time.sleep(wait)
continue
raise Exception(f"API call failed: {response.status_code}")Concurrent Refresh
What if two Lambda invocations try to refresh the same token simultaneously? Use DynamoDB conditional writes or implement a distributed lock with DynamoDB TTL items.
For BalancingIQ, I accept the race condition risk because:
- Refresh tokens don't expire on use (both refreshes succeed)
- The last write wins, which is fine
- It's rare (users don't trigger multiple parallel API calls)
If your system has high concurrency, implement proper locking.
IAM Permissions for Lambda
Your Lambda functions need specific IAM permissions. Here's the minimal policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:region:account:table/BookkeepingIntegrations"
},
{
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt"
],
"Resource": "arn:aws:kms:region:account:key/your-key-id"
}
]
}Monitoring and Debugging
OAuth issues are invisible to users until something breaks. Implement observability:
CloudWatch Logs
Log every OAuth event with structured data:
print(json.dumps({
'event': 'token_refresh',
'business_id': business_id,
'provider': 'xero',
'expires_at': expires_at,
'time_until_expiry': expires_at - now,
'success': True
}))CloudWatch Alarms
Set up alarms for:
- Refresh failures: Alert when refresh_token fails
- Rate limit errors: Too many 429 responses
- Missing integrations: API calls with no stored auth
DynamoDB Metrics
Track integration health in DynamoDB:
# Add metadata to each integration record
{
'businessId': 'biz_123',
'sortKey': 'xero#tenant_456',
...
'lastRefreshAt': 1706112000,
'lastRefreshSuccess': True,
'lastApiCallAt': 1706115600,
'consecutiveFailures': 0
}Use this to identify stale integrations or recurring failures.
Security Checklist
Before going to production, verify:
- PKCE implemented for all OAuth flows
- State parameter validated on callback
- Refresh tokens encrypted with KMS
- Tokens stored with multi-tenant isolation
- httpOnly cookies for PKCE data
- Short cookie expiry (≤10 minutes)
- Client secrets never exposed to frontend
- Automatic token refresh implemented
- Token expiry buffer (60s) to account for clock skew
- Revoked access handled gracefully
- Rate limiting with exponential backoff
- All OAuth events logged
- CloudWatch alarms for failures
- IAM permissions follow principle of least privilege
Common Mistakes to Avoid
After implementing OAuth for three different products, here are the pitfalls I see most often:
1. Storing Tokens in localStorage
Never store OAuth tokens in localStorage or sessionStorage. Any JavaScript on your page (including third-party scripts) can access them. Use httpOnly cookies or server-side storage only.
2. Not Using PKCE
"But I'm using a client secret", doesn't matter. PKCE protects against authorization code interception. Always use it, even with confidential clients.
3. Ignoring Token Rotation
Some providers (QuickBooks, Microsoft) rotate refresh tokens. If you don't update your stored token, the old one becomes invalid after one use.
4. Hard-Coding Redirect URIs
Use environment variables for redirect URIs. You'll need different URIs for localhost, staging, and production.
5. Not Handling Revoked Access
Users can revoke access at any time. When refresh fails with invalid_grant, delete the integration and notify the user, don't keep retrying forever.
Conclusion
OAuth 2.0 is deceptively complex. The basic flow is simple, but production-ready implementations require:
- PKCE for authorization code protection
- State validation for CSRF prevention
- KMS encryption for token storage
- Automatic refresh with proper buffering
- Multi-tenant isolation with composite keys
- Comprehensive error handling for revoked access, rate limits, and failures
- Observability for debugging when things go wrong
This architecture has been battle-tested in BalancingIQ (financial integrations) and SOA Assist Pro (Microsoft Outlook), handling thousands of OAuth flows without security incidents.
The complexity is worth it. Once you have this foundation, adding new OAuth providers is straightforward, just implement the provider-specific endpoints and you're done.
Building OAuth integrations? I've implemented secure OAuth for Xero, QuickBooks, Microsoft Outlook, and other platforms. If you're building similar integrations or need help with production OAuth, reach out at adamdugan6@gmail.com
The code examples are directly from my own production code bases. Not generated by LLMs. LLMs were used to help with research and article structure.