I needed an OAuth2 identity provider for my SaaS projects that met EU Consumer Act and GDPR requirements. When I looked at existing solutions, the pricing shocked me: Auth0 started at €200+ per month for the features I needed, and AWS Cognito’s complexity made me nervous about properly configuring compliance settings.
As a solo developer with AWS experience and tight budget constraints, I asked myself: could I build this for less while maintaining full control over EU compliance? The answer turned out to be yes.
The result is idp8.com—a fully functional OAuth2 provider running on AWS Lambda and DynamoDB, protected by Cloudflare’s anti-bot features, handling thousands of authentication requests monthly for approximately €20/month.
This article walks through the technical architecture, compliance implementation, and cost breakdown of building your own identity provider when Auth0’s pricing doesn’t make sense.
Before committing to building a custom solution, I evaluated the obvious alternatives.
Auth0’s pricing model breaks down like this:
For a bootstrapped SaaS with uncertain growth, committing to €200/month before product-market fit felt risky. The free tier limitations (no custom domains, basic auth only) made it unsuitable for production use.
Cognito has attractive pay-per-use pricing but comes with tradeoffs:
I wanted full control over how user data was handled, stored, and made available for GDPR requests.
I decided to build when I realized:
For teams without AWS experience or those expecting rapid, unpredictable scale, buying still makes more sense. For my use case, building was the right call.
Before writing any code, I mapped out exactly what compliance meant for an identity provider operating in the EU.
Right of Access (GDPR Article 15): Users must be able to request and receive all personal data you hold about them in a structured, machine-readable format.
Right to Erasure (GDPR Article 17): Users can request deletion of their data, and you must comply within 30 days unless you have legitimate grounds to refuse.
Data Portability (GDPR Article 20): Users must be able to export their data in a common format (JSON, CSV) for transfer to another service.
Consent Management: You must track explicit consent for data processing, allow withdrawal of consent, and document the legal basis for processing.
Data Residency: For EU users, personal data should be stored and processed within EU regions to simplify compliance.
Breach Notification: You must detect, document, and notify users of data breaches within 72 hours.
Data Protection by Design: Build security and privacy into the architecture from the start, not as an afterthought.
Data Export Endpoint:
I created an API endpoint /users/{user_id}/export that returns all user data as JSON: profile information, authentication history, consent records, and session logs.
Account Deletion Workflow:
The /users/{user_id}/delete endpoint marks accounts for deletion, triggers a cleanup Lambda that removes personal data from DynamoDB, anonymizes audit logs, and sends confirmation email within 24 hours.
Consent Tracking: A separate DynamoDB table stores consent records with timestamps, purpose descriptions, and consent status. Every authentication flow checks and logs current consent state.
Data Residency:
All AWS resources (Lambda, DynamoDB, S3) are deployed exclusively in eu-west-1 (Ireland) and eu-central-1 (Frankfurt) regions.
Audit Logging: Every authentication event, data access, and administrative action is logged to S3 with immutable object locking enabled for compliance audits.
Encryption: DynamoDB encryption at rest is enabled by default, Lambda environment variables are encrypted with KMS, and all API traffic uses TLS 1.2+.
The system uses a serverless architecture to minimize fixed costs while maintaining scalability.
[User Browser]
↓
[Cloudflare (anti-bot, rate limiting)]
↓
[API Gateway (HTTPS endpoints)]
↓
[Lambda Functions (business logic)]
↓
[DynamoDB (user data, tokens, consent)]
↓
[S3 (audit logs, backups)]
API Gateway:
Exposes REST endpoints for OAuth2 flows: /authorize, /token, /userinfo, /revoke. Handles TLS termination, request validation, and integrates with Lambda.
Lambda Functions: Separate functions for each concern: authorization handler, token generator, user management, consent tracking, data export. Written in Node.js for fast cold starts.
DynamoDB: Three tables: Users (profile, credentials), Clients (OAuth2 client registrations), Tokens (access tokens, refresh tokens, authorization codes). On-demand billing mode for unpredictable traffic.
Cloudflare: Sits in front of API Gateway custom domain, provides bot protection, rate limiting, DDoS mitigation, and caching for static responses. Free tier covers basic needs.
AWS KMS: Generates and manages signing keys for JWT tokens, encrypts sensitive Lambda environment variables (database connection strings, API secrets).
S3: Stores compliance audit logs with lifecycle policies (retain 2 years, then archive to Glacier). Also used for user data export files.
CloudWatch: Logs all Lambda execution, API Gateway requests, and custom application metrics for monitoring authentication success rates and error patterns.
Authentication traffic is spiky: high during business hours, low overnight. Serverless pricing aligns perfectly with this pattern:
For a service handling 50,000 requests per month, serverless costs roughly €20/month. A constantly running EC2 instance would cost €30-50/month even if idle 90% of the time.
I implemented the most commonly used OAuth2 flows with security best practices.
This is the primary flow for web and mobile applications.
Flow steps:
/authorize with client_id, redirect_uri, scope, and PKCE parameters (code_challenge, code_challenge_method)/token endpoint, providing PKCE code_verifierWhy PKCE matters: PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks, especially important for mobile and single-page applications. I made it mandatory for all authorization flows.
Access Tokens (JWT):
I use JWT format for access tokens with this structure:
{
"iss": "https://idp8.com",
"sub": "user_123456",
"aud": "client_abc",
"exp": 1643723400,
"iat": 1643719800,
"scope": "read:profile write:data",
"tenant_id": "tenant_xyz"
}
Signed with RS256 (RSA SHA-256) using KMS-managed private keys. Public keys are exposed at /.well-known/jwks.json for clients to verify signatures.
Token Expiration:
Refresh Token Rotation: Each time a refresh token is used to obtain a new access token, I issue a new refresh token and invalidate the old one. This limits the damage if a refresh token is compromised.
Scopes define what permissions a client has:
read:profile - Read basic user informationwrite:profile - Update user profileread:data - Access user datawrite:data - Modify user dataadmin - Administrative access (restricted)Scopes are stored in the Clients table during client registration and validated during token issuance.
For machine-to-machine authentication (backend services), I implemented the simpler client credentials flow:
client_id and client_secret to /token endpointThis is used for automated jobs, API integrations, and service accounts.
I chose a multi-table design over single-table for clarity and simplicity at my scale.
Primary Key: user_id (partition key)
Attributes:
email (string, unique)email_verified (boolean)password_hash (string, bcrypt hashed)first_name, last_name (strings)created_at, updated_at (timestamps)tenant_id (string, for multi-tenancy)consent_status (map of consent purposes → boolean)GSI: email-index (for login lookups)
Primary Key: client_id (partition key)
Attributes:
client_secret (string, hashed)client_name (string)redirect_uris (list of allowed redirect URIs)allowed_scopes (list of permitted scopes)tenant_id (string)created_at (timestamp)Primary Key: token_id (partition key)
Attributes:
token_type (string: “authorization_code”, “refresh_token”)client_id (string)user_id (string)scope (string)code_challenge (string, for PKCE)expires_at (number, Unix timestamp)used (boolean, for authorization codes)created_at (timestamp)TTL: expires_at (DynamoDB automatically deletes expired tokens)
GSI: user_id-index (for listing user’s active tokens)
Primary Key: consent_id (partition key)
Attributes:
user_id (string)purpose (string: “authentication”, “data_processing”, “marketing”)granted (boolean)granted_at (timestamp)withdrawn_at (timestamp, nullable)GSI: user_id-index (for consent record queries)
Single-table design optimizes for DynamoDB’s pricing model by reducing the number of tables. For my use case:
I prioritized clarity and compliance auditability over marginal cost savings.
Cloudflare sits in front of the entire system and provides critical security features that would be expensive to implement in AWS.
The login page at login.idp8.com uses Cloudflare Turnstile (their CAPTCHA replacement) to prevent automated credential stuffing attacks.
How it works:
This catches 99%+ of bot traffic before it reaches AWS, saving Lambda invocations and DynamoDB reads.
Cloudflare’s rate limiting rules protect critical endpoints:
/authorize: 10 requests per minute per IP/token: 5 requests per minute per IP/login: 3 failed attempts per 5 minutes per IP (locks out brute force)These rules run at Cloudflare’s edge, blocking malicious traffic before it incurs AWS costs.
Cloudflare’s network absorbs volumetric DDoS attacks automatically. During a recent 50,000 request/second attack (likely testing), Cloudflare blocked 99.8% of traffic. My AWS bill showed no spike—normal €20 for the month.
Without Cloudflare, that attack would have cost hundreds of euros in Lambda invocations and DynamoDB writes.
Cloudflare provides the custom domain (idp8.com) with free SSL certificates. This saves the cost and complexity of managing Route 53 and ACM certificates in AWS.
Setup:
Combining Cloudflare (free tier) with AWS serverless gives you:
It’s the best of both worlds: AWS’s flexible serverless compute with Cloudflare’s security and global edge network.
Security for an OAuth2 provider must be airtight. Here’s how I locked down each layer.
Hashing: bcrypt with cost factor 12 (adjustable as hardware improves)
Storage: Only password hashes stored in DynamoDB, never plaintext
Complexity requirements: Minimum 8 characters, must include uppercase, lowercase, number, special character (enforced at registration and password change)
Breach detection: Passwords checked against Have I Been Pwned API during registration (5 million+ compromised passwords blocked)
Key management: RSA 2048-bit key pair generated and stored in AWS KMS
Signing process:
Sign API with SHA-256 hash of payloadVerification: Clients download public key from /.well-known/jwks.json and verify signature locally (no KMS call needed)
Key rotation: Automated annual rotation with overlap period where both old and new keys are valid
Lambda environment variables: Encrypted with KMS customer-managed keys
Client secrets: Hashed with bcrypt before storing in Clients table (same as passwords)
API keys: Stored in AWS Secrets Manager with automatic rotation every 90 days
No hardcoded secrets: All configuration loaded from environment variables or Secrets Manager at runtime
Every API endpoint validates input rigorously:
Cloudflare: First line of defense, blocks at edge
API Gateway: 10,000 requests per second burst limit, 5,000 steady-state per account
Application logic: Custom rate limiting in Lambda tracks failed login attempts per user/IP and implements exponential backoff
Failed login attempts:
Every security-relevant event is logged to S3:
Logs are immutable (S3 Object Lock enabled), retained for 2 years, then archived to Glacier for 5 more years.
Dependency scanning: npm audit runs in CI/CD, blocks deployment if high/critical vulnerabilities found
Lambda runtime updates: Automated monthly updates to latest Node.js LTS runtime
Penetration testing: Annual third-party pen test (required for some client contracts)
Here’s the actual monthly cost breakdown from my AWS bill:
AWS Lambda:
DynamoDB:
API Gateway:
S3:
AWS KMS:
CloudWatch:
Route 53:
Cloudflare:
Total Monthly Cost: €18.78
Rounded to €20/month for variability.
For equivalent features:
| Feature | Auth0 Professional | idp8.com (AWS) |
|---|---|---|
| Basic OAuth2 | €200/month | €20/month |
| 10,000 MAU | €200/month | €20/month |
| Custom domain | Included | €0.50/month (Route 53) |
| Bot protection | Add-on €50/month | Free (Cloudflare) |
| Audit logs | Included | €0.51/month (S3) |
| Total | €250/month | €20/month |
Savings: €230/month or €2,760/year
At 100,000 monthly active users, Auth0 pricing jumps to €500+/month. My AWS costs would increase to approximately €40/month (still 12x cheaper).
Auth0 becomes competitive when:
For bootstrapped founders with technical chops, building is clearly cheaper.
Building an OAuth2 provider taught me plenty through trial and error.
Problem: The OAuth2 spec has many edge cases not obvious from reading the RFC.
Examples:
Solution: I studied Auth0 and Okta’s documentation extensively, read OAuth2 security best practices (RFC 8252, RFC 8628), and implemented the strictest reasonable interpretation of each requirement.
Problem: DynamoDB’s query limitations bit me multiple times.
Example: I needed to look up users by email (not the primary key). My first attempt did a full table scan on every login—absurdly slow and expensive.
Solution: Added a Global Secondary Index on email. Lesson learned: design GSIs upfront based on access patterns, not as an afterthought.
Problem: First request after idle period took 800ms (unacceptable for login flows).
Solutions:
Trade-off: Provisioned concurrency adds cost but improves user experience. I enable it 9am-6pm CET, disable overnight.
Problem: OAuth2 requires HTTPS, redirect URIs, and proper domain resolution—hard to replicate locally.
Solution:
localhost redirect URIsProblem: EU compliance isn’t just technical—it requires documentation, privacy policies, terms of service, and data processing agreements.
Solution:
Lesson: Technical implementation is 60% of compliance. Documentation and legal review are the other 40%.
Problem: I wanted multiple SaaS projects to use idp8.com, but isolated from each other.
Solution:
tenant_id to every DynamoDB tabletenant_id from JWT contextThis was more complex than single-tenant but essential for reusability across projects.
After building and operating idp8.com for several months, I have clear opinions on when building makes sense.
For solo developers and small teams with technical skills, building is almost always more cost-effective. For larger teams or non-technical founders, buying makes sense.
You can’t manage what you don’t measure. Here’s how I monitor idp8.com in production.
Authentication success rate: % of /token requests that succeed (target: >99.5%)
Latency percentiles: p50, p95, p99 for each endpoint (target: <200ms p95)
Error rates: 4xx and 5xx errors per endpoint (target: <0.1% 5xx)
Token issuance volume: Requests/minute to detect unusual spikes
Failed login attempts: Track brute force attempts per IP/user
DynamoDB throttling: Read/write capacity exceeded events (should be zero on on-demand)
CloudWatch Dashboards: Real-time graphs of Lambda invocations, DynamoDB operations, API Gateway requests
CloudWatch Alarms: Alert via SNS (email/SMS) when:
CloudWatch Logs Insights: Query Lambda logs for debugging, filter by user_id or client_id to trace issues
X-Ray: Distributed tracing for complex requests that touch multiple Lambda functions and DynamoDB tables
When an alarm fires:
Average time to resolve P1 incidents: 15 minutes.
idp8.com meets my current needs, but there’s always room to expand.
Social login: Add “Sign in with Google” and “Sign in with GitHub” for better UX
Passwordless authentication: Implement magic link and WebAuthn/passkey support
Multi-factor authentication: SMS, TOTP, and hardware key support
User management UI: Web console for admins to manage users, clients, and view audit logs
Terraform configuration: Replace manual AWS console setup with infrastructure-as-code
Automated penetration testing: Integrate security scanning into CI/CD pipeline
Current architecture handles ~1 million requests/month comfortably. To scale to 10 million+:
At 100 million requests/month, costs would rise to approximately €150/month—still 50% cheaper than Auth0’s enterprise tier.
Building idp8.com proved that you don’t need a huge budget or enterprise SaaS to run a production-grade OAuth2 provider that meets EU compliance requirements. With serverless AWS architecture and Cloudflare’s free security features, I achieved:
The key lessons:
For solo developers and bootstrapped startups, this pattern is repeatable: use AWS serverless for compute/storage, Cloudflare for security/edge, and invest time in understanding the domain (OAuth2, compliance) rather than paying for a managed service.
If you’re building a SaaS and need authentication, consider whether Auth0’s convenience is worth 10-12x the cost. For many technical founders, the answer is no—and building might be the better path.
Now go build your own OAuth2 provider (or adapt this pattern for your authentication needs). Your wallet and compliance auditors will thank you.