AWS Builder Center
From Manual OAuth Onboarding to Event-Driven Sync: A Privacy-Safe Serverless Case Study

From Manual OAuth Onboarding to Event-Driven Sync: A Privacy-Safe Serverless Case Study

A privacy-safe case study on how a manual OAuth onboarding flow evolved into a secure, low-cost serverless architecture with automatic resource discovery, encrypted token storage, and event-driven DynamoDB-to-Postgres synchronization.

TL;DR

This write-up is intentionally anonymized and educational. Names, identifiers, and implementation details are generalized. Code snippets are partial by design.

Why this story matters

Many data integrations start with a "quick workaround":
  • Share an account
  • Send credentials in a ticket
  • Manually export account IDs
  • Hardcode resource mappings
It works for one client, then breaks at ten.
This case study walks through how we moved from manual onboarding to a secure, low-cost serverless OAuth platform that automatically discovers analytics resources and keeps downstream relational data synchronized through event-driven streaming.
The point is not to copy a vendor-specific integration. The point is to learn the architecture and decision process so you can apply it to your own system.

The initial problem statement

We had a repeatable operational pain:
  • External users needed to authorize read-only access to analytics and advertising data.
  • Onboarding required human steps and back-and-forth communication.
  • Metadata lived in one place, but reporting and internal workflows relied on a relational database.
  • Security and privacy constraints required strict token handling.

Non-negotiable constraints

  1. No raw OAuth tokens in app logs or primary metadata tables.
  2. CSRF-safe OAuth flow.
  3. Low operational overhead and low monthly cost.
  4. Near real-time sync into Postgres for downstream consumers.
  5. Publicly shareable architecture knowledge without exposing business logic.

Solution v1: serverless OAuth discovery pipeline

We started with a simple but strong baseline:
  • API Gateway for HTTPS endpoints
  • Lambda for orchestration and read APIs
  • DynamoDB for metadata and nonce state
  • SSM Parameter Store (SecureString) for token material
  • CloudWatch for observability

High-level architecture (v1)

OAuth flow with single-use state nonce

The first major security decision: treat state as one-time and time-bound.
1
2
3
4
5
6
7
8
# pseudo-code
state = uuid4()
put_item("OAuthStates", {
"state": state,
"created_at": now_epoch(),
"ttl": now_epoch() + 600
})
redirect_to_provider(state=state)
On callback:
1
2
3
4
5
6
7
8
# pseudo-code
record = get_item("OAuthStates", key={"state": state})
if not record:
return unauthorized("invalid or reused state")

delete_item("OAuthStates", key={"state": state}) # consume once
if record.ttl < now_epoch():
return unauthorized("state expired")
Why both TTL and explicit delete?
  • TTL cleanup is eventually consistent.
  • Explicit delete enforces one-time use immediately.

Token boundary: secrets vs metadata

A second core decision: split storage responsibilities.
  • Sensitive tokens -> encrypted secret store (SSM SecureString)
  • Non-sensitive metadata -> DynamoDB
1
2
3
4
5
6
7
8
9
10
11
12
13
# pseudo-code
def store_tokens(email, access_token, refresh_token):
safe_email = sanitize_for_path(email)
put_secure(f"/oauth/{safe_email}/access_token", access_token)
put_secure(f"/oauth/{safe_email}/refresh_token", refresh_token)

def upsert_token_metadata(email, scopes):
put_item("OAuthTokenMeta", {
"user_email": email,
"scopes": scopes,
"has_tokens": True,
"updated_at": now_epoch()
})
Security impact:
  • Querying metadata tables never reveals token material.
  • Access control can differ per store.
  • Incident blast radius is reduced.

Why we moved away from Cognito for this specific flow

An earlier design considered using Cognito as the center of authentication and token handling.
That approach is strong when your main goal is application sign-in and session management (JWT-based identity, federation, and authorization boundaries). But our integration path needed something slightly different: reliable custody of the external provider's actual access token and refresh token so backend jobs could call provider APIs over time.
In practice, Cognito-issued tokens represent Cognito sessions, not a full replacement for long-lived third-party API credential lifecycle management in this architecture. So we changed direction:
  • Use direct OAuth code exchange with the provider.
  • Persist provider access/refresh tokens in encrypted parameter storage.
  • Keep DynamoDB focused on non-sensitive token metadata and discovery records.
This decision reduced ambiguity in token ownership, made refresh behavior explicit, and aligned better with least-privilege backend API access patterns.

Resource discovery pipeline

After callback exchange, the auth Lambda performs discovery against two provider APIs and stores normalized records.
1
2
3
4
5
6
7
8
9
10
# pseudo-code
tokens = exchange_code_for_tokens(code, redirect_uri)
email = fetch_user_identity(tokens.access_token)
store_tokens(email, tokens.access_token, tokens.refresh_token)

resources_a = list_resource_type_a(tokens.access_token)
resources_b, skipped = list_resource_type_b(tokens.refresh_token)

upsert_resources_a(email, resources_a)
upsert_resources_b(email, resources_b, skipped)
Notice the mixed token strategy:
  • API A accepts access token directly.
  • API B may be better served from refresh-token derived sessions.
This small detail matters in real-world provider ecosystems.

New problem: operational analytics lived in Postgres, not DynamoDB

V1 solved onboarding and discovery. Then a second problem appeared.
Downstream consumers (dashboards, joins, historical analysis, role-based reports) relied on relational querying in Postgres. But fresh data now landed in DynamoDB first.
We had to answer:
  • How do we keep relational tables synced with minimal lag?
  • How do we remain idempotent under retries and duplicate events?
  • How do we avoid expensive full-table scans every minute?

Solution v2: event-driven sync with DynamoDB Streams

The best fit was an event-driven projection layer:
  • DynamoDB table updates emit stream records.
  • Stream processor Lambda transforms records.
  • Lambda upserts rows into Postgres.

Updated architecture with streaming sync

Why event-driven first, batch second

Primary path (event-driven):
  • Low latency (seconds)
  • No frequent scans
  • Natural fit for change-data-capture style projection
Safety net (nightly reconciliation):
  • Catches rare drift (missed events, temporary DB outage, mapping regressions)
  • Supports audit checks and backfills
This is a practical engineering pattern: fast path + correctness path.

Stream processor design details

1. Idempotency via SQL upsert

DynamoDB Streams are at-least-once delivery. Duplicate records can happen. Upsert semantics make retries safe.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- pseudo-SQL
INSERT INTO ext_resource_a (
user_email,
resource_id,
resource_name,
status,
updated_at
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_email, resource_id)
DO UPDATE SET
resource_name = EXCLUDED.resource_name,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at;

2. Record-level routing

1
2
3
4
5
6
7
8
# pseudo-code
for rec in event.records:
table = detect_source_table(rec)
if rec.event_name in ["INSERT", "MODIFY"]:
row = map_new_image_to_row(table, rec.new_image)
upsert_postgres(table, row)
elif rec.event_name == "REMOVE":
soft_delete_or_mark_inactive(table, rec.keys)

3. Preserve source truth semantics

Not every delete should be a physical delete in Postgres. Often better:
  • Keep row
  • Mark status = inactive
  • Track synced_at and source_updated_at
This improves auditability and historical reporting.

4. Backpressure and failure handling

For production, configure:
  • Batch size tuned for row payload
  • Retries + DLQ (or failure destination)
  • Per-table metrics for lag and failure counts
1
2
3
4
5
6
7
8
# pseudo-SAM fragment
EventSourceMapping:
Type: DynamoDB
Properties:
StartingPosition: LATEST
BatchSize: 100
MaximumRetryAttempts: 3
BisectBatchOnFunctionError: true

Security-by-design decisions (and why)

CSRF-safe OAuth state

Single-use, TTL-bound nonce in DynamoDB reduced callback forgery risk.

Token isolation

Only secret store contains token values. Metadata table stores "token exists" and consent timestamps.

Least privilege IAM

Each Lambda role should have only:
  • read/write specific DynamoDB tables it uses
  • limited SSM parameter path access
  • CloudWatch log permissions
  • network access only when needed (sync Lambda inside VPC for RDS)

Logging hygiene

Never log:
  • authorization code
  • access token
  • refresh token
  • raw provider error objects that may contain sensitive context
Log instead:
  • operation outcome
  • provider endpoint class
  • masked subject identifiers
  • correlation ID

Cost-aware architecture choices

The design intentionally kept fixed cost low:
  • Lambda for bursty orchestration
  • API Gateway for managed ingress
  • DynamoDB on-demand for uncertain traffic
  • SSM Parameter Store SecureString instead of a heavier secret system for this phase
  • 30-day log retention to control CloudWatch growth
Rough POC economics can stay small (single-digit USD/month) when traffic is modest and retention is disciplined.

Sequence walkthrough (problem to resolution)

Small implementation snippets you can adapt

Sanitize identity for secret path keys

1
2
3
4
5
# pseudo-code
import re

def to_secret_path_segment(identity: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]", "_", identity)

Build the callback URI dynamically to avoid template coupling

1
2
3
4
5
# pseudo-code
def callback_uri_from_event(event):
domain = event.requestContext.domainName
stage = event.requestContext.stage
return f"https://{domain}/{stage}/auth/callback"

Separate "active" and "skipped" discovered resources

1
2
3
4
# pseudo-code
active, skipped = discover_resources()
upsert_active(active)
upsert_skipped(skipped, reason_field="skip_reason")

Keep a reconciliation watermark

1
2
3
4
5
-- pseudo-SQL
CREATE TABLE sync_checkpoint (
pipeline_name text primary key,
last_reconciled_at timestamptz not null
);

Design trade-offs and what changed in architecture

What improved from v1 to v2

  1. Onboarding became self-service instead of support-driven.
  2. Token handling became boundary-safe and auditable.
  3. Metadata became immediately queryable in DynamoDB.
  4. Relational consumers received near real-time updates via streams.
  5. Operational resilience improved with nightly reconciliation.

New complexity introduced (and accepted)

  1. Stream processor deployment and monitoring.
  2. VPC networking for Lambda-to-RDS connectivity.
  3. Schema mapping ownership between NoSQL and SQL models.
These are acceptable because they buy reliability, lower manual effort, and better consumer experience.

What this case study intentionally does not reveal

To protect privacy and commercial implementation details, this post excludes:
  • Real account names, tenants, domains, and identifiers
  • Production table names and environment values
  • End-to-end source code and full function implementations
  • Internal business workflows, SLAs, and organization-specific goals
That is not a weakness. It is a publishing discipline.

Final architecture summary

Final design in one sentence:
A serverless OAuth ingestion service writes secure secrets and normalized metadata, then projects metadata changes into Postgres through an idempotent event-driven stream processor, with scheduled reconciliation for correctness.
If you are designing a similar platform, the key pattern to remember is:
  • Keep secrets and metadata in separate trust boundaries.
  • Use event streams for freshness.
  • Add periodic reconciliation for confidence.
  • Design cost and security as first-class constraints, not post-launch patches.

Practical rollout checklist

  1. Ship OAuth nonce validation and one-time consumption first.
  2. Enforce token/metadata split before production traffic.
  3. Add provider discovery with partial-failure handling (active vs skipped).
  4. Enable streams on metadata tables.
  5. Implement idempotent Postgres upsert projection.
  6. Add nightly reconciliation and drift metrics.
  7. Lock down IAM and log redaction rules.
  8. Track cost and lag dashboards from day one.

Closing note

Architecture maturity usually arrives in stages, not all at once. First you remove manual pain. Then you harden security boundaries. Then you solve data movement with event-driven design. If you do those steps intentionally, you can stay both secure and cost-effective while your system grows.

Resources

  • OAuth 2.0 Authorization Framework (RFC 6749): https://datatracker.ietf.org/doc/html/rfc6749
  • OAuth 2.0 Threat Model and Security Considerations (RFC 6819): https://datatracker.ietf.org/doc/html/rfc6819
  • AWS Lambda Developer Guide: https://docs.aws.amazon.com/lambda/latest/dg/welcome.html
  • Amazon API Gateway Developer Guide: https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html
  • Amazon DynamoDB Developer Guide: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html
  • DynamoDB Streams and Lambda event source mappings: https://docs.aws.amazon.com/lambda/latest/dg/services-dynamodb-eventsourcemapping.html
  • AWS Systems Manager Parameter Store (SecureString): https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html
  • AWS Well-Architected Framework (Security and Cost pillars): https://docs.aws.amazon.com/wellarchitected/latest/framework/welcome.html
  • Amazon RDS for PostgreSQL User Guide: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_PostgreSQL.html
  • PostgreSQL INSERT ... ON CONFLICT (UPSERT): https://www.postgresql.org/docs/current/sql-insert.html
  • Python requests documentation: https://requests.readthedocs.io/en/latest/
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article