When building mini-apps on LUKSO, you'll encounter a significant challenge: mini-apps run in iframes, but OAuth authentication (like Twitter login) doesn't work properly in iframes. This guide provides a complete solution by implementing a popup-based authentication flow.
The Problem: Mini-apps on LUKSO run within iframes under domains like universaleverything.io, but OAuth redirects don't work in iframes.
Our Solution: A popup window approach that maintains connection between the user's Universal Profile wallet and their Twitter account.
Before starting, make sure you have:
- A mini-app project (you can use LUKSO's mini-app NextJS template)
- Twitter Developer Account with API keys
- Firebase project set up
- Node.js environment
Required packages:
- next-auth (for Twitter authentication)
- firebase-admin (for database storage)
- @lukso/up-provider (for Universal Profile integration)
First, create a Firebase service account and set up the Firebase configuration. Create a file at src/lib/firebase.ts:
// lib/firebase.ts
import * as admin from 'firebase-admin';
// Check if already initialized to prevent multiple initializations
if (!admin.apps.length) {
try {
// Parse the service account from a JSON string in environment variable
const serviceAccount = JSON.parse(
process.env.FIREBASE_SERVICE_ACCOUNT_KEY || '{}'
);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: `https://${process.env.FIREBASE_PROJECT_ID}.firebaseio.com`
});
console.log('Firebase Admin initialized successfully');
} catch (error) {
console.error('Firebase Admin initialization error:', error);
throw new Error(`Firebase initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Export the Firestore instance
const db = admin.firestore();
export { db };Create an API endpoint to store Twitter data in Firebase. Create a file at src/app/api/save-twitter/route.ts:
// app/api/save-twitter/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/firebase';
import { FieldValue } from 'firebase-admin/firestore';
interface TwitterData {
walletAddress: string;
twitterId: string;
twitterUsername: string;
twitterHandle?: string;
twitterImage?: string;
canBeRoasted?: boolean;
}
interface SuccessResponse {
success: true;
message: string;
}
interface ErrorResponse {
success: false;
error: string;
}
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const body: TwitterData = await request.json();
const {
walletAddress,
twitterId,
twitterUsername,
twitterHandle,
twitterImage,
canBeRoasted = true
} = body;
// Validate input
if (!walletAddress || !twitterId || !twitterUsername) {
return NextResponse.json(
{ success: false, error: 'Missing required fields' } as ErrorResponse,
{ status: 400 }
);
}
if (!walletAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
return NextResponse.json(
{ success: false, error: 'Invalid wallet address format' } as ErrorResponse,
{ status: 400 }
);
}
const normalizedAddress = walletAddress.toLowerCase();
const userDocRef = db.collection('users').doc(normalizedAddress);
await userDocRef.set(
{
walletAddress: normalizedAddress,
twitterId,
twitterUsername,
twitterHandle: twitterHandle || twitterUsername,
twitterImage: twitterImage || null,
canBeRoasted,
createdAt: FieldValue.serverTimestamp(), // Use server timestamp
},
{ merge: true }
);
return NextResponse.json({
success: true,
message: 'Twitter account connected successfully'
} as SuccessResponse);
} catch (error) {
console.error('Error saving Twitter account:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Twitter data'
} as ErrorResponse, { status: 500 });
}
}Create an API endpoint to retrieve the linked Twitter data. Create a file at src/app/api/user/route.ts:
// app/api/user/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/firebase';
import * as admin from 'firebase-admin';
interface SuccessResponse {
success: true;
exists: boolean;
data: admin.firestore.DocumentData | null;
isRoastable?: boolean;
}
interface ErrorResponse {
success: false;
error: string;
}
export async function GET(request: NextRequest): Promise<NextResponse> {
const searchParams = request.nextUrl.searchParams;
const walletAddress = searchParams.get('address');
if (!walletAddress) {
return NextResponse.json(
{ success: false, error: 'Wallet address is required' } as ErrorResponse,
{ status: 400 }
);
}
if (!walletAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
return NextResponse.json(
{ success: false, error: 'Invalid wallet address format' } as ErrorResponse,
{ status: 400 }
);
}
try {
const normalizedAddress = walletAddress.toLowerCase();
const docRef = db.collection('users').doc(normalizedAddress);
const docSnap = await docRef.get();
if (docSnap.exists) {
return NextResponse.json({
success: true,
exists: true,
data: docSnap.data(),
isRoastable: docSnap.data()?.canBeRoasted || false
} as SuccessResponse);
} else {
return NextResponse.json({
success: true,
exists: false,
data: null
} as SuccessResponse);
}
} catch (error) {
console.error('API route error:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ErrorResponse, { status: 500 });
}
}Create the NextAuth configuration. Create a file at src/utils/authOptions.ts:
// utils/authOptions.ts
import { AuthOptions } from "next-auth";
import TwitterProvider from "next-auth/providers/twitter";
export const authOptions: AuthOptions = {
providers: [
TwitterProvider({
clientId: process.env.TWITTER_CLIENT_ID || "",
clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
version: "2.0",
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.id = account.providerAccountId;
token.accessToken = account.access_token;
}
if (profile) {
token.twitterHandle = profile.data.username;
token.image = profile.data.profile_image_url;
token.email = profile.data.email;
}
return token;
},
async session({ session, token }) {
session.user.id = token.id;
session.user.twitterHandle = token.twitterHandle;
session.user.image = token.image;
session.user.email = token.email;
return session;
},
async redirect({ url, baseUrl }) {
// Redirect to the authenticatedX page after successful login
return `${baseUrl}/authenticatedX`;
},
},
pages: {
signIn: "/signin", // Custom sign-in page
},
};Create the NextAuth API route. Create a file at src/app/api/auth/[...nextauth]/route.ts:
// app/api/auth/[...nextauth]/route.ts
import { authOptions } from "@/utils/authOptions";
import NextAuth, { AuthOptions } from "next-auth";
const handler = NextAuth(authOptions as AuthOptions);
export { handler as GET, handler as POST };Create a simple sign-in page that redirects to Twitter. Create a file at src/app/signin/page.tsx:
// app/signin/page.tsx
"use client";
import { useEffect } from "react";
import { signIn } from "next-auth/react";
export default function SignIn() {
useEffect(() => {
signIn("twitter", { callbackUrl: "/authenticatedX" });
}, []);
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<h1 className="text-2xl font-bold mb-4">Redirecting to Twitter...</h1>
<p className="mb-6">Please wait while we redirect you to Twitter for authentication.</p>
<div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
</div>
);
}Create the authentication page that handles the Twitter callback and saves the data. Create a file at src/app/authenticatedX/page.tsx:
// app/authenticatedX/page.tsx
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
// Create a separate component for the authentication content
export default function AuthenticatedX() {
const { data: session, status } = useSession();
const searchParams = useSearchParams();
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [initialRedirectDone, setInitialRedirectDone] = useState(false);
// Get wallet from URL parameter
const walletFromUrl = searchParams.get('wallet');
// Get wallet from localStorage if it exists
const [walletFromStorage, setWalletFromStorage] = useState<string | null>(null);
// Initialize walletFromStorage from localStorage
useEffect(() => {
try {
const storedWallet = localStorage.getItem('twitter_auth_wallet');
setWalletFromStorage(storedWallet);
} catch (e) {
console.error('Error accessing localStorage:', e);
}
}, []);
// Handle initial redirect to Twitter auth
useEffect(() => {
if (walletFromUrl && !initialRedirectDone) {
try {
// Store the wallet in localStorage
localStorage.setItem('twitter_auth_wallet', walletFromUrl);
setWalletFromStorage(walletFromUrl);
setInitialRedirectDone(true);
// Redirect to Twitter auth after a short delay
setTimeout(() => {
const authUrl = `/api/auth/signin/twitter?callbackUrl=${encodeURIComponent('/authenticatedX')}`;
window.location.href = authUrl;
}, 100);
} catch (error) {
console.error("Error during wallet storage:", error);
setSaveError("Failed to store wallet address. Please try again.");
}
}
}, [walletFromUrl, initialRedirectDone]);
// Save Twitter data when we have both session and wallet
useEffect(() => {
const saveTwitterData = async () => {
// Only proceed if we have both authenticated session and wallet address
if (status !== "authenticated" || !session?.user || saving || saveSuccess || !walletFromStorage) {
return;
}
try {
setSaving(true);
setSaveError(null);
const response = await fetch('/api/save-twitter', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
walletAddress: walletFromStorage,
twitterId: session.user.id,
twitterUsername: session.user.name,
twitterHandle: session.user.twitterHandle,
twitterImage: session.user.image,
canBeRoasted: true // You can customize this based on your needs
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `HTTP error! status: ${response.status}`);
}
if (!result.success) {
throw new Error(result.error || 'Operation failed');
}
// Success! Clear wallet from localStorage and close window
setSaveSuccess(true);
localStorage.removeItem('twitter_auth_wallet');
setTimeout(() => window.close(), 2000);
} catch (error) {
console.error("Error saving Twitter data:", error);
setSaveError(
error instanceof Error
? error.message
: 'Failed to save Twitter data. Please try again.'
);
} finally {
setSaving(false);
}
};
saveTwitterData();
}, [session, status, saving, saveSuccess, walletFromStorage]);
// Render different UI states based on the current status
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
{/* Initial loading with wallet from URL */}
{walletFromUrl && !walletFromStorage && (
<>
<h1 className="text-2xl font-bold mb-4">Connecting to Twitter...</h1>
<div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
</>
)}
{/* Authenticating with Twitter */}
{walletFromStorage && status === "loading" && (
<>
<h1 className="text-2xl font-bold mb-4">Authenticating with Twitter...</h1>
<div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
</>
)}
{/* Saving data after authentication */}
{status === "authenticated" && saving && (
<>
<h1 className="text-2xl font-bold mb-4">Linking your Twitter account...</h1>
<div className="w-12 h-12 border-4 border-blue-400 border-t-blue-600 rounded-full animate-spin"></div>
</>
)}
{/* Success state */}
{saveSuccess && (
<>
<h1 className="text-2xl font-bold mb-4 text-green-600">Successfully Linked!</h1>
<p>Your Twitter account has been linked to your Universal Profile.</p>
<p className="mt-2 text-sm text-gray-500">This window will close automatically...</p>
</>
)}
{/* Error state */}
{saveError && (
<>
<h1 className="text-2xl font-bold mb-4 text-red-600">Error</h1>
<p className="text-red-500">{saveError}</p>
<button
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => window.close()}
>
Close Window
</button>
</>
)}
</div>
);
}Now, add a button to your mini-app to initiate the Twitter authentication:
// In your main app component
"use client";
import { useState } from 'react';
import { useUpProvider } from '@/components/upProvider'; // Import from your UP Provider
export default function YourMiniApp() {
const [twitterAuthLoading, setTwitterAuthLoading] = useState(false);
const { contextAccounts } = useUpProvider(); // Get the wallet address from UP Provider
const handleTwitterAuth = () => {
setTwitterAuthLoading(true);
// Get the Universal Profile address
const walletAddress = contextAccounts?.[0] || '';
// Calculate window position for center of screen
const width = 600;
const height = 700;
const left = window.innerWidth / 2 - width / 2;
const top = window.innerHeight / 2 - height / 2;
// Open popup window for Twitter auth
window.open(
`/authenticatedX?wallet=${encodeURIComponent(walletAddress)}`,
'Twitter Authentication',
`width=${width},height=${height},left=${left},top=${top}`
);
// Add event listener to detect when popup closes
window.addEventListener('message', (event) => {
if (event.data === 'twitter_auth_complete') {
setTwitterAuthLoading(false);
// Refresh your UI or fetch updated data
}
});
};
return (
<div>
<h1>My LUKSO Mini-App</h1>
<button
onClick={handleTwitterAuth}
disabled={twitterAuthLoading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{twitterAuthLoading ? "Connecting..." : "Connect Twitter Account"}
</button>
</div>
);
}Create a .env.local file in your project root with the following variables:
# Twitter API credentials
TWITTER_CLIENT_ID=your_twitter_client_id
TWITTER_CLIENT_SECRET=your_twitter_client_secret
# Firebase configuration
FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"your-project-id",...}
FIREBASE_PROJECT_ID=your-project-id
# NextAuth configuration
NEXTAUTH_URL=your_app_url
NEXTAUTH_SECRET=your_nextauth_secretNow let's walk through the entire flow to understand how the pieces work together:
When a user clicks "Connect Twitter Account" in your mini-app:
- The
handleTwitterAuthfunction gets the host's Universal Profile address (contextAccounts[0]). If you want to save visitor's Twitter you'd pass accounts[0] - Opens a popup window and passes the address as a URL parameter
In the /authenticatedX page:
- The popup extracts the wallet address from the URL
- Stores it in
localStoragefor safekeeping (because iframe localStorage is isolated from popup localStorage) - Then redirects to Twitter authentication
- User authenticates with Twitter
- Twitter redirects back to your callback URL (
/authenticatedX) - NextAuth handles the OAuth flow and creates a session with Twitter data
Back in the /authenticatedX page after Twitter auth:
- The page now has access to both:
- Twitter data (from NextAuth session)
- Universal Profile address (from localStorage)
- The page sends both to your API endpoint (
/api/save-twitter) - The API saves this connection in Firebase
- The popup window closes automatically
Later, to retrieve the connected Twitter account:
- Call your API endpoint (
/api/user?address=0x123...) - The API looks up the document in Firebase using the wallet address
- Returns the connected Twitter data
Alternatively, you could auto-fetch connected Twitter account after 10-15 seconds because that's how long authentication via X will take, but in case user is authenticated faster for best UX you could track if user is authenticating via useState (userClickedXLogin) and if it's true, your Login button would fetch the API endpoint. Reason we do this is because we don't want to complicated things by trying to communicate between the pop-up and parent window.
This solution works around the iframe limitations because:
- Isolation Bypass: The popup window runs on your domain, not in the iframe
- State Preservation: localStorage in the popup maintains the wallet address during the OAuth flow
- Seamless UX: The popup handles everything automatically and closes when done
- Data Persistence: The wallet β Twitter connection is stored in Firebase, retrievable from any context
If the popup is blocked by the browser:
- Add a fallback link that opens in a new tab
- Inform users to allow popups for your site
If localStorage access fails:
- Check for cross-origin or privacy settings issues
- Consider adding a fallback method like URL parameters
If Twitter authentication fails:
- Verify your Twitter API credentials
- Check the callback URL in your Twitter developer dashboard
- Ensure the NextAuth configuration is correct
- Data Validation: Always validate wallet addresses before saving them
- Environment Variables: Keep API keys and secrets in environment variables
- JWT Security: Set a strong NEXTAUTH_SECRET for secure sessions
- Error Handling: Implement proper error handling throughout the flow
This is not a foolproof setup for the following reason: the address is stored in localstorage as a string. This means a malicious script could replace user's address with another one. This would cause your database to save user's Twitter account to the wrong address causing UI/UX issues. For best practice, we recommend setting up server side signature scheme. First - when visiting AuthenticatedX/page.tsx the URL should contain timestamped signed message with expiration date that matches your server side private key's public address. This ensures that the Twitter authentication page is only viewable via the Login flow. Second - instead of storing the address directly as a string, sign it with the server (timestamped as well) and later validate that the signature is yours and has not expired.
src/
βββ app/
β βββ api/
β β βββ auth/
β β β βββ [...nextauth]/
β β β βββ route.ts
β β βββ save-twitter/
β β β βββ route.ts
β β βββ user/
β β βββ route.ts
β βββ authenticatedX/
β β βββ page.tsx
β βββ signin/
β β βββ page.tsx
β βββ page.tsx (your mini-app main page)
βββ components/
β βββ upProvider.tsx (from LUKSO template)
βββ lib/
β βββ firebase.ts
βββ utils/
βββ authOptions.ts
- LUKSO Mini-App NextJS Template
- NextAuth.js Documentation
- Firebase Admin SDK Documentation
- Twitter API Documentation
By following this guide, you can successfully implement Twitter OAuth authentication in your LUKSO mini-apps, allowing users to connect their social media accounts to their Universal Profiles despite the iframe limitations of the platform.