Authentication Integration
Complete guide to implementing Link2Pay's passwordless wallet authentication in your application.
Overview
Link2Pay uses cryptographic signature-based authentication instead of traditional passwords:
Benefits:
- No passwords to store or manage
- No risk of password leaks
- Built on Stellar's ed25519 cryptography
- Users authenticate with wallet they already own
- Non-custodial (keys never leave user's wallet)
Flow:
- User connects Freighter wallet
- Backend issues a nonce (challenge)
- User signs nonce with private key
- Backend verifies signature
- Session token issued (JWT)
Authentication Flow
Frontend Implementation
1. Connect Wallet
typescript
// hooks/useWallet.ts
import { getPublicKey, getNetwork } from '@stellar/freighter-api';
import { useState } from 'react';
export function useWallet() {
const [wallet, setWallet] = useState<{
publicKey: string;
networkPassphrase: string;
} | null>(null);
async function connect() {
try {
// Request public key from Freighter
const publicKey = await getPublicKey();
// Get network
const { networkPassphrase } = await getNetwork();
setWallet({ publicKey, networkPassphrase });
return { publicKey, networkPassphrase };
} catch (error) {
if (error.message === 'User declined access') {
throw new Error('Please approve wallet connection');
}
throw error;
}
}
function disconnect() {
setWallet(null);
}
return {
wallet,
connected: !!wallet,
connect,
disconnect
};
}
// Usage
function WalletButton() {
const { connected, wallet, connect, disconnect } = useWallet();
if (connected) {
return (
<div>
<span>{wallet!.publicKey.slice(0, 8)}...</span>
<button onClick={disconnect}>Disconnect</button>
</div>
);
}
return <button onClick={connect}>Connect Wallet</button>;
}2. Request Nonce
typescript
// services/auth.ts
const API_URL = 'https://api.link2pay.dev';
export async function requestNonce(walletAddress: string) {
const response = await fetch(`${API_URL}/api/auth/nonce`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ walletAddress })
});
if (!response.ok) {
throw new Error('Failed to request nonce');
}
return response.json();
}
// Response:
// {
// nonce: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
// message: "Link2Pay Authentication\nWallet: GABC...\nNonce: a1b2c3d4...\nTimestamp: 2024-03-07T12:00:00.000Z",
// expiresIn: 300
// }3. Sign Message
typescript
import { signAuthEntry } from '@stellar/freighter-api';
export async function signMessage(
message: string,
walletAddress: string
): Promise<string> {
try {
const signature = await signAuthEntry(message, {
accountToSign: walletAddress
});
return signature;
} catch (error) {
if (error.message === 'User declined access') {
throw new Error('Please approve the signature request in your wallet');
}
throw error;
}
}4. Get Session Token
typescript
export async function getSessionToken(
walletAddress: string,
nonce: string,
signature: string
) {
const response = await fetch(`${API_URL}/api/auth/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
walletAddress,
nonce,
signature
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Authentication failed');
}
return response.json();
}
// Response:
// {
// token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
// expiresAt: "2024-03-07T13:00:00.000Z",
// walletAddress: "GABC..."
// }5. Complete Auth Hook
typescript
// hooks/useAuth.ts
import { useState, useEffect } from 'react';
import { useWallet } from './useWallet';
import { requestNonce, signMessage, getSessionToken } from '../services/auth';
export function useAuth() {
const { wallet } = useWallet();
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Load token from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('auth_token');
const expires = localStorage.getItem('auth_expires');
if (stored && expires) {
const expiresAt = new Date(expires).getTime();
if (Date.now() < expiresAt) {
setToken(stored);
} else {
// Token expired
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_expires');
}
}
}, []);
async function authenticate() {
if (!wallet) {
throw new Error('Wallet not connected');
}
setLoading(true);
try {
// 1. Request nonce
const { nonce, message } = await requestNonce(wallet.publicKey);
// 2. Sign message
const signature = await signMessage(message, wallet.publicKey);
// 3. Get session token
const { token, expiresAt } = await getSessionToken(
wallet.publicKey,
nonce,
signature
);
// 4. Store token
setToken(token);
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_expires', expiresAt);
return token;
} catch (error) {
throw error;
} finally {
setLoading(false);
}
}
function logout() {
setToken(null);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_expires');
}
return {
token,
loading,
isAuthenticated: !!token,
authenticate,
logout
};
}
// Usage
function ProtectedPage() {
const { isAuthenticated, authenticate, loading } = useAuth();
if (!isAuthenticated) {
return (
<div>
<h1>Sign In Required</h1>
<button onClick={authenticate} disabled={loading}>
{loading ? 'Authenticating...' : 'Sign In with Wallet'}
</button>
</div>
);
}
return <Dashboard />;
}Backend Implementation
1. Nonce Generation
typescript
// services/authService.ts
import crypto from 'crypto';
interface NonceRecord {
nonce: string;
walletAddress: string;
expiresAt: number;
}
// In-memory nonce store (use Redis in production)
const nonceStore = new Map<string, NonceRecord>();
export class AuthService {
// Issue nonce
issueNonce(walletAddress: string): string {
const nonce = crypto.randomBytes(16).toString('hex');
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
nonceStore.set(nonce, {
nonce,
walletAddress,
expiresAt
});
// Clean up expired nonces
this.cleanupExpiredNonces();
return nonce;
}
// Build message to sign
buildMessage(walletAddress: string, nonce: string): string {
return `Link2Pay Authentication
Wallet: ${walletAddress}
Nonce: ${nonce}
Timestamp: ${new Date().toISOString()}`;
}
// Verify nonce is valid
verifyNonce(walletAddress: string, nonce: string): boolean {
const record = nonceStore.get(nonce);
if (!record) {
return false; // Nonce not found
}
if (record.walletAddress !== walletAddress) {
return false; // Wrong wallet
}
if (Date.now() > record.expiresAt) {
nonceStore.delete(nonce);
return false; // Expired
}
// Consume nonce (one-time use)
nonceStore.delete(nonce);
return true;
}
private cleanupExpiredNonces() {
const now = Date.now();
for (const [nonce, record] of nonceStore.entries()) {
if (now > record.expiresAt) {
nonceStore.delete(nonce);
}
}
}
}
export const authService = new AuthService();2. Signature Verification
typescript
import { Keypair } from '@stellar/stellar-sdk';
export class AuthService {
// ... previous methods
verifySignature(
walletAddress: string,
nonce: string,
signature: string
): boolean {
try {
// 1. Verify nonce
if (!this.verifyNonce(walletAddress, nonce)) {
return false;
}
// 2. Rebuild message
const message = this.buildMessage(walletAddress, nonce);
// 3. Verify ed25519 signature
const keypair = Keypair.fromPublicKey(walletAddress);
const messageBuffer = Buffer.from(message, 'utf-8');
const signatureBuffer = Buffer.from(signature, 'hex');
return keypair.verify(messageBuffer, signatureBuffer);
} catch (error) {
console.error('Signature verification error:', error);
return false;
}
}
}3. JWT Token Generation
typescript
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const TOKEN_EXPIRY = '1h'; // 1 hour
export class AuthService {
// ... previous methods
issueSessionToken(walletAddress: string) {
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
const token = jwt.sign(
{ walletAddress },
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY }
);
return {
token,
expiresAt: expiresAt.toISOString(),
walletAddress
};
}
verifyToken(token: string): { walletAddress: string } | null {
try {
return jwt.verify(token, JWT_SECRET) as { walletAddress: string };
} catch (error) {
return null;
}
}
}4. Auth Routes
typescript
// routes/auth.ts
import { Router } from 'express';
import { z } from 'zod';
import { authService } from '../services/authService';
const router = Router();
const nonceSchema = z.object({
walletAddress: z
.string()
.regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar address')
});
const sessionSchema = z.object({
walletAddress: z
.string()
.regex(/^G[A-Z2-7]{55}$/),
nonce: z.string().regex(/^[a-fA-F0-9]{32}$/),
signature: z.string().regex(/^[a-fA-F0-9]+$/)
});
// POST /api/auth/nonce
router.post('/nonce', (req, res) => {
try {
const { walletAddress } = nonceSchema.parse(req.body);
const nonce = authService.issueNonce(walletAddress);
const message = authService.buildMessage(walletAddress, nonce);
res.json({
nonce,
message,
expiresIn: 300 // seconds
});
} catch (error) {
res.status(400).json({ error: 'Invalid request' });
}
});
// POST /api/auth/session
router.post('/session', (req, res) => {
try {
const { walletAddress, nonce, signature } = sessionSchema.parse(req.body);
const valid = authService.verifySignature(walletAddress, nonce, signature);
if (!valid) {
return res.status(401).json({
error: 'Invalid or expired signature. Request a new nonce.'
});
}
const session = authService.issueSessionToken(walletAddress);
res.json(session);
} catch (error) {
res.status(400).json({ error: 'Invalid request' });
}
});
export default router;5. Auth Middleware
typescript
// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { authService } from '../services/authService';
export interface AuthRequest extends Request {
walletAddress?: string;
}
export function requireAuth(
req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.substring(7);
const payload = authService.verifyToken(token);
if (!payload) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
req.walletAddress = payload.walletAddress;
next();
} catch (error) {
res.status(401).json({ error: 'Authentication failed' });
}
}
// Usage
app.get('/api/invoices', requireAuth, (req: AuthRequest, res) => {
const walletAddress = req.walletAddress!;
// ... fetch user's invoices
});Production Considerations
1. Use Redis for Nonce Storage
typescript
// services/authService.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export class AuthService {
async issueNonce(walletAddress: string): Promise<string> {
const nonce = crypto.randomBytes(16).toString('hex');
// Store in Redis with 5-minute TTL
await redis.setex(
`nonce:${nonce}`,
300,
JSON.stringify({ walletAddress, createdAt: Date.now() })
);
return nonce;
}
async verifyNonce(walletAddress: string, nonce: string): Promise<boolean> {
const data = await redis.get(`nonce:${nonce}`);
if (!data) {
return false;
}
const record = JSON.parse(data);
if (record.walletAddress !== walletAddress) {
return false;
}
// Consume nonce (delete from Redis)
await redis.del(`nonce:${nonce}`);
return true;
}
}2. Refresh Tokens
typescript
// Generate refresh token (long-lived)
issueRefreshToken(walletAddress: string): string {
return jwt.sign(
{ walletAddress, type: 'refresh' },
JWT_SECRET,
{ expiresIn: '7d' } // 7 days
);
}
// Refresh access token
async refreshAccessToken(refreshToken: string) {
const payload = jwt.verify(refreshToken, JWT_SECRET);
if (payload.type !== 'refresh') {
throw new Error('Invalid refresh token');
}
return this.issueSessionToken(payload.walletAddress);
}
// Route
router.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
try {
const session = authService.refreshAccessToken(refreshToken);
res.json(session);
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});3. Rate Limiting
typescript
import rateLimit from 'express-rate-limit';
const nonceRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 requests per window
message: { error: 'Too many authentication attempts' }
});
router.post('/nonce', nonceRateLimiter, (req, res) => {
// ... handle nonce request
});4. HTTPS Only
typescript
// middleware/httpsOnly.ts
export function httpsOnly(req, res, next) {
if (req.protocol !== 'https' && process.env.NODE_ENV === 'production') {
return res.status(403).json({
error: 'HTTPS required for authentication'
});
}
next();
}
app.use('/api/auth', httpsOnly, authRouter);Security Best Practices
1. Validate Wallet Address
typescript
function isValidStellarAddress(address: string): boolean {
return /^G[A-Z2-7]{55}$/.test(address);
}
router.post('/nonce', (req, res) => {
const { walletAddress } = req.body;
if (!isValidStellarAddress(walletAddress)) {
return res.status(400).json({ error: 'Invalid wallet address' });
}
// ... continue
});2. Prevent Replay Attacks
Nonces are single-use and time-limited:
typescript
// ✅ Good: Nonce consumed after verification
verifyNonce(walletAddress, nonce) {
const record = nonceStore.get(nonce);
// ... verify
nonceStore.delete(nonce); // Delete immediately
return true;
}
// ❌ Bad: Nonce can be reused
verifyNonce(walletAddress, nonce) {
const record = nonceStore.get(nonce);
// ... verify
// Nonce not deleted! Can be used again
return true;
}3. Secure JWT Secret
bash
# Generate strong secret
openssl rand -hex 32
# .env
JWT_SECRET=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f24. Token Expiration
typescript
// Short-lived access tokens
const ACCESS_TOKEN_EXPIRY = '1h';
// Long-lived refresh tokens
const REFRESH_TOKEN_EXPIRY = '7d';
// Verify expiration on every request
function requireAuth(req, res, next) {
const payload = jwt.verify(token, JWT_SECRET);
// JWT library automatically checks expiration
// Will throw error if expired
req.walletAddress = payload.walletAddress;
next();
}Testing
Unit Tests
typescript
// auth.test.ts
import { authService } from '../services/authService';
import { Keypair } from '@stellar/stellar-sdk';
describe('AuthService', () => {
it('should issue and verify nonce', () => {
const wallet = 'GABC...';
const nonce = authService.issueNonce(wallet);
expect(nonce).toHaveLength(32);
const valid = authService.verifyNonce(wallet, nonce);
expect(valid).toBe(true);
// Nonce should be consumed
const invalid = authService.verifyNonce(wallet, nonce);
expect(invalid).toBe(false);
});
it('should verify ed25519 signature', () => {
const keypair = Keypair.random();
const wallet = keypair.publicKey();
const nonce = authService.issueNonce(wallet);
const message = authService.buildMessage(wallet, nonce);
// Sign message
const signature = keypair.sign(Buffer.from(message)).toString('hex');
const valid = authService.verifySignature(wallet, nonce, signature);
expect(valid).toBe(true);
});
it('should reject expired nonce', async () => {
const wallet = 'GABC...';
const nonce = authService.issueNonce(wallet);
// Wait for expiration
jest.useFakeTimers();
jest.advanceTimersByTime(6 * 60 * 1000); // 6 minutes
const valid = authService.verifyNonce(wallet, nonce);
expect(valid).toBe(false);
});
});Troubleshooting
Issue: Signature verification always fails
Causes:
- Message format doesn't match
- Wrong network (testnet vs mainnet keys)
- Signature encoding mismatch
Solution:
typescript
// Debug signature verification
console.log('Wallet:', walletAddress);
console.log('Nonce:', nonce);
console.log('Message:', message);
console.log('Signature:', signature);
// Try verifying manually
const keypair = Keypair.fromPublicKey(walletAddress);
const messageBuffer = Buffer.from(message, 'utf-8');
const signatureBuffer = Buffer.from(signature, 'hex');
const valid = keypair.verify(messageBuffer, signatureBuffer);
console.log('Valid:', valid);Issue: Nonce expired
Causes:
- User takes > 5 minutes to sign
- Clock skew between client/server
- Nonce already consumed
Solution:
- Increase nonce TTL to 10 minutes
- Request new nonce on expiration
- Show countdown timer to user
Next Steps
- Read Frontend Integration
- Learn about Backend Integration
- Explore Webhook Events
- Check Security Guide