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:

  1. Redirect user to provider's authorization URL
  2. User grants permission
  3. Provider redirects back with an authorization code
  4. Exchange code for access token
  5. Make API calls with access token

But this leaves out critical production concerns:

Let's solve all of these with a production-ready architecture.

Architecture Overview

Here's the full stack for BalancingIQ's OAuth implementation:

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:

  1. Generating a random code_verifier (a secret)
  2. Hashing it to create a code_challenge
  3. Sending the challenge (not the verifier) to the authorization server
  4. 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

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:

  1. Validate the state parameter (CSRF protection)
  2. Exchange the authorization code for tokens using PKCE
  3. Retrieve the organization/tenant information
  4. 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:

This means:

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

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

QuickBooks Online

Microsoft Outlook (SOA Assist Pro)

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:

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:

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:

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:

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

← Back to blog

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.