How to Implement Sign in with Apple Using Cloudflare Workers: A Complete Guide
Apple’s Sign in with Apple provides a secure, privacy-focused authentication solution for iOS apps. This guide shows you how to build a complete authentication system using Sign in with Apple on iOS with a Cloudflare Workers backend for token validation and user data synchronization.
Why This Architecture?
Traditional authentication backends require managing servers, but Cloudflare Workers offer a serverless solution that scales automatically and provides excellent performance globally. Combined with D1 (Cloudflare’s SQL database), you get a complete backend solution without server management overhead.
Architecture Overview
Our implementation consists of three main components:
- iOS App: Handles Sign in with Apple authentication locally
- Cloudflare Worker: Validates Apple tokens and manages user data
- D1 Database: Stores user profiles, progress, and sync data
iOS App ↔ Cloudflare Worker ↔ D1 Database
↗ Apple ID Servers (token validation)
Part 1: Apple Developer Portal Setup
1. Configure Your App ID
First, enable Sign in with Apple for your app:
- Visit Apple Developer Portal
- Navigate to Certificates, Identifiers & Profiles → Identifiers
- Select your app identifier
- Enable “Sign In with Apple” capability
- Configure as “Enable as a primary App ID”
2. Create a Service ID
For server-side token validation, create a Service ID:
- In Identifiers, click + button
- Select “Services IDs”
- Enter description:
"Your App Web Service"
- Enter identifier:
"com.yourcompany.yourapp.webservice"
- Enable “Sign In with Apple”
- Configure domains:
- Domain:
your-worker.your-subdomain.workers.dev
- Return URL:
https://your-worker.your-subdomain.workers.dev/auth/callback
- Domain:
3. Generate Private Key
Create a private key for token signing:
- Navigate to Keys section
- Click + button
- Enter key name:
"Your App Sign in with Apple"
- Enable “Sign In with Apple”
- Configure with your App ID
- Download the .p8 file and store it securely
- Note the Key ID shown after creation
4. Note Your Team ID
Find your Team ID in the top right of the developer portal or in the Membership section.
Part 2: Cloudflare Worker Implementation
Project Structure
Create a new directory for your worker:
worker/
├── src/
│ ├── index.ts # Main request handler
│ ├── auth.ts # Apple token validation
│ ├── sync.ts # User data synchronization
│ └── types.ts # TypeScript interfaces
├── schema.sql # Database schema
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
└── wrangler.toml # Cloudflare configuration
Database Schema
Create schema.sql
for user data storage:
-- Users table for storing Apple Sign-in user data
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
appleId TEXT NOT NULL UNIQUE,
totalXP INTEGER DEFAULT 0,
currentLevel INTEGER DEFAULT 1,
streakDays INTEGER DEFAULT 0,
lastActiveDate TEXT,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Progress tracking for lessons/content
CREATE TABLE IF NOT EXISTS progress (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
beliefSystemId TEXT NOT NULL,
lessonId TEXT,
status TEXT NOT NULL CHECK (status IN ('not_started', 'in_progress', 'completed')),
score INTEGER,
earnedXP INTEGER DEFAULT 0,
completedAt TEXT,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
-- User achievements tracking
CREATE TABLE IF NOT EXISTS user_achievements (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
achievementId TEXT NOT NULL,
progress INTEGER DEFAULT 0,
isCompleted BOOLEAN DEFAULT FALSE,
completedAt TEXT,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(userId, achievementId)
);
TypeScript Types
Define your data structures in types.ts
:
export interface Env {
DB: D1Database;
APPLE_TEAM_ID: string;
APPLE_CLIENT_ID: string;
APPLE_KEY_ID: string;
APPLE_PRIVATE_KEY: string;
}
export interface User {
id: string;
name: string;
email: string;
appleId: string;
totalXP: number;
currentLevel: number;
streakDays: number;
lastActiveDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface AppleTokenPayload {
iss: string; // Issuer (Apple)
aud: string; // Audience (your client ID)
exp: number; // Expiration time
iat: number; // Issued at time
sub: string; // Subject (Apple user ID)
email?: string; // User email (optional)
email_verified?: string;
auth_time: number;
}
Apple Token Validation
The core of the authentication system is in auth.ts
:
import { Env, AppleTokenPayload } from './types';
export class AppleAuth {
private env: Env;
constructor(env: Env) {
this.env = env;
}
async verifyIdentityToken(identityToken: string): Promise<AppleTokenPayload | null> {
try {
// 1. Decode token header to get key ID
const [headerBase64] = identityToken.split('.');
const headerJson = atob(headerBase64);
const header = JSON.parse(headerJson);
const keyId = header.kid;
// 2. Fetch Apple's public keys
const keysResponse = await fetch('https://appleid.apple.com/auth/keys');
const keys = await keysResponse.json();
// 3. Find matching public key
const publicKey = keys.keys.find((key: any) => key.kid === keyId);
if (!publicKey) {
console.error('Public key not found');
return null;
}
// 4. Import the public key for verification
const jwk = {
kty: publicKey.kty,
n: publicKey.n,
e: publicKey.e,
alg: publicKey.alg,
use: publicKey.use,
};
const key = await crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify']
);
// 5. Verify token signature
const [, payloadBase64, signatureBase64] = identityToken.split('.');
const signatureBuffer = this.base64UrlToBuffer(signatureBase64);
const dataToVerify = new TextEncoder().encode(`${headerBase64}.${payloadBase64}`);
const isValid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
key,
signatureBuffer,
dataToVerify
);
if (!isValid) {
console.error('Token signature invalid');
return null;
}
// 6. Decode and validate payload
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload: AppleTokenPayload = JSON.parse(payloadJson);
// 7. Validate token claims
const now = Math.floor(Date.now() / 1000);
if (payload.iss !== 'https://appleid.apple.com') {
console.error('Invalid issuer');
return null;
}
if (payload.aud !== this.env.APPLE_CLIENT_ID) {
console.error('Invalid audience');
return null;
}
if (payload.exp < now) {
console.error('Token expired');
return null;
}
return payload;
} catch (error) {
console.error('Error verifying Apple identity token:', error);
return null;
}
}
private base64UrlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const base64WithPadding = base64 + padding;
const binaryString = atob(base64WithPadding);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
extractUserInfo(payload: AppleTokenPayload) {
return {
appleId: payload.sub,
email: payload.email,
emailVerified: payload.email_verified === 'true',
};
}
}
Main Request Handler
The main worker logic in index.ts
:
import { Env, SyncData } from './types';
import { AppleAuth } from './auth';
import { SyncService } from './sync';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// CORS headers for web compatibility
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Route: User data synchronization
if (url.pathname === '/sync' && request.method === 'POST') {
return await handleSync(request, env, corsHeaders);
}
// Route: Health check
if (url.pathname === '/health' && request.method === 'GET') {
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', { status: 404, headers: corsHeaders });
} catch (error) {
console.error('Worker error:', error);
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
},
};
async function handleSync(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
// Use Apple user ID from header for authentication
const appleUserId = request.headers.get('X-Apple-User-Id');
if (!appleUserId) {
return new Response(JSON.stringify({ error: 'Missing X-Apple-User-Id header' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
try {
const syncData: SyncData = await request.json();
const syncService = new SyncService(env);
const syncedData = await syncService.syncData(appleUserId, syncData);
return new Response(JSON.stringify(syncedData), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Sync error:', error);
return new Response(
JSON.stringify({
error: 'Sync failed',
details: error instanceof Error ? error.message : 'Unknown error',
}),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}
Configuration
Set up wrangler.toml
:
name = "your-app-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
# D1 Database binding
[[d1_databases]]
binding = "DB"
database_name = "your-app-database"
database_id = "your-database-id-here"
# Environment variables are set as secrets via wrangler secret put
# APPLE_TEAM_ID, APPLE_CLIENT_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY
Part 3: iOS App Implementation
AuthenticationManager
Create a Swift class to handle Apple Sign In:
import Foundation
import AuthenticationServices
import CryptoKit
protocol AuthenticationManagerDelegate: AnyObject {
func authenticationDidComplete(with result: Result<AppleIDCredential, Error>)
}
class AuthenticationManager: NSObject {
weak var delegate: AuthenticationManagerDelegate?
// MARK: - Public Methods
func signInWithApple() {
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
// Use nonce for security
let nonce = randomNonceString()
request.nonce = sha256(nonce)
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
}
func signOut() {
UserDefaults.standard.removeObject(forKey: "appleUserId")
UserDefaults.standard.removeObject(forKey: "appleUserEmail")
UserDefaults.standard.removeObject(forKey: "appleUserFullName")
UserDefaults.standard.removeObject(forKey: "appleIdentityToken")
}
var isSignedIn: Bool {
return UserDefaults.standard.string(forKey: "appleUserId") != nil
}
// MARK: - Private Methods
private func randomNonceString(length: Int = 32) -> String {
precondition(length > 0)
let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
var result = ""
var remainingLength = length
while remainingLength > 0 {
let randoms: [UInt8] = (0..<16).map { _ in
var random: UInt8 = 0
let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
if errorCode != errSecSuccess {
fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)")
}
return random
}
randoms.forEach { random in
if remainingLength == 0 {
return
}
if random < charset.count {
result.append(charset[Int(random)])
remainingLength -= 1
}
}
}
return result
}
private func sha256(_ input: String) -> String {
let inputData = Data(input.utf8)
let hashedData = SHA256.hash(data: inputData)
let hashString = hashedData.compactMap {
String(format: "%02x", $0)
}.joined()
return hashString
}
}
// MARK: - ASAuthorizationControllerDelegate
extension AuthenticationManager: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
// Store user information
UserDefaults.standard.set(appleIDCredential.user, forKey: "appleUserId")
if let email = appleIDCredential.email {
UserDefaults.standard.set(email, forKey: "appleUserEmail")
}
if let fullName = appleIDCredential.fullName {
let displayName = PersonNameComponentsFormatter().string(from: fullName)
UserDefaults.standard.set(displayName, forKey: "appleUserFullName")
}
if let identityToken = appleIDCredential.identityToken,
let tokenString = String(data: identityToken, encoding: .utf8) {
UserDefaults.standard.set(tokenString, forKey: "appleIdentityToken")
}
delegate?.authenticationDidComplete(with: .success(appleIDCredential))
}
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
delegate?.authenticationDidComplete(with: .failure(error))
}
}
// MARK: - ASAuthorizationControllerPresentationContextProviding
extension AuthenticationManager: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
return UIApplication.shared.windows.first!
}
}
SyncManager
Handle data synchronization with the Cloudflare Worker:
import Foundation
class SyncManager {
private let baseURL = "https://your-worker.your-subdomain.workers.dev"
func syncUserData(completion: @escaping (Result<SyncResponse, Error>) -> Void) {
guard let appleUserId = UserDefaults.standard.string(forKey: "appleUserId") else {
completion(.failure(SyncError.notAuthenticated))
return
}
guard let url = URL(string: "\(baseURL)/sync") else {
completion(.failure(SyncError.invalidURL))
return
}
// Prepare sync data
let syncData = SyncData(
user: getCurrentUser(),
progress: getLocalProgress(),
achievements: getLocalAchievements(),
lastSyncDate: UserDefaults.standard.string(forKey: "lastSyncDate")
)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(appleUserId, forHTTPHeaderField: "X-Apple-User-Id")
do {
request.httpBody = try JSONEncoder().encode(syncData)
} catch {
completion(.failure(error))
return
}
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(SyncError.noData))
return
}
do {
let syncResponse = try JSONDecoder().decode(SyncResponse.self, from: data)
// Update local data with server response
self.updateLocalData(with: syncResponse)
// Update last sync date
UserDefaults.standard.set(Date().iso8601String, forKey: "lastSyncDate")
completion(.success(syncResponse))
} catch {
completion(.failure(error))
}
}.resume()
}
private func getCurrentUser() -> User? {
// Implement based on your local user data structure
// Return current user data from local storage/database
return nil
}
private func getLocalProgress() -> [Progress] {
// Implement based on your local progress data structure
return []
}
private func getLocalAchievements() -> [UserAchievement] {
// Implement based on your local achievements data structure
return []
}
private func updateLocalData(with syncResponse: SyncResponse) {
// Implement local data updates based on server response
// This should merge server data with local data using conflict resolution
}
}
// MARK: - Data Models
struct SyncData: Codable {
let user: User?
let progress: [Progress]
let achievements: [UserAchievement]
let lastSyncDate: String?
}
struct SyncResponse: Codable {
let user: User
let progress: [Progress]
let achievements: [UserAchievement]
let lastSyncDate: String?
}
enum SyncError: Error {
case notAuthenticated
case invalidURL
case noData
}
extension Date {
var iso8601String: String {
return ISO8601DateFormatter().string(from: self)
}
}
Part 4: Deployment and Configuration
Deploy the Worker
- Create D1 Database:
cd worker
npx wrangler d1 create your-app-database
-
Update wrangler.toml with the database ID from the output
-
Initialize Database Schema:
npx wrangler d1 execute your-app-database --file=./schema.sql
- Set Environment Secrets:
# Set your Apple private key (contents of .p8 file)
npx wrangler secret put APPLE_PRIVATE_KEY
# Set other Apple configuration
npx wrangler secret put APPLE_TEAM_ID
npx wrangler secret put APPLE_CLIENT_ID
npx wrangler secret put APPLE_KEY_ID
- Deploy Worker:
npx wrangler deploy
Update iOS Configuration
- Add Entitlements: Create
YourApp.entitlements
:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>
- Update SyncManager with your worker URL:
private let baseURL = "https://your-worker.your-subdomain.workers.dev"
Part 5: Security Best Practices
Token Security
- Always validate tokens on the server side
- Never trust client-provided authentication data
- Implement proper token expiration handling
- Use secure storage for sensitive data
Privacy Considerations
- Only request necessary user data scopes
- Respect user privacy choices
- Implement proper data retention policies
- Handle email hiding scenarios gracefully
Error Handling
- Implement comprehensive error handling for network failures
- Handle authentication state changes gracefully
- Provide meaningful error messages to users
- Log security-relevant events for monitoring
Conclusion
This implementation provides a complete, production-ready Sign in with Apple solution using Cloudflare Workers. The architecture offers several advantages:
- Serverless scaling: Automatically handles traffic spikes
- Global performance: Cloudflare’s edge network ensures low latency
- Cost-effective: Pay only for what you use
- Security: Built-in DDoS protection and secure token validation
- Privacy-focused: Minimal data collection, respecting user privacy
The combination of Apple’s privacy-first authentication with Cloudflare’s edge computing platform creates a powerful, scalable backend solution that can grow with your application while maintaining excellent performance and security standards.
For production deployments, consider implementing additional features like rate limiting, monitoring, and backup strategies to ensure a robust authentication system for your users.