Static Tokens & Physical Authentication
Use static tokens to create reusable authentication URLs for physical locations, enabling both online and in-person use cases.
What are Static Tokens?
Static tokens are unique identifiers you can embed in authentication URLs to create persistent, location-specific authentication points. Unlike regular authentication URLs that are single-use, static token URLs can be:
- Printed as QR codes on physical materials
- Written to NFC stickers for contactless authentication
- Reused indefinitely without regeneration
- Location-specific to track where requests originate
Why Use Static Tokens?
Static tokens enable Portal to work in the physical world, not just online:
✅ Restaurant Tables - Print QR codes on tables for payment requests ✅ Office Access - NFC stickers on doors for authentication ✅ Event Check-in - Unique codes per entrance for tracking ✅ Vending Machines - Physical payment endpoints ✅ Hotel Rooms - Room-specific authentication for services ✅ Retail Checkout - Counter-specific payment requests
How It Works
1. Generate URL with Static Token
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
// Create reusable URL with static token
const staticToken = 'table-14-restaurant-a';
const authUrl = await client.newKeyHandshakeUrl(
(mainKey, preferredRelays) => {
console.log(`Authentication from: ${staticToken}`);
console.log(`User: ${mainKey}`);
// Handle based on location
handleLocationAuth(staticToken, mainKey);
},
staticToken // Static token parameter
);
console.log('Reusable URL:', authUrl);
// This URL can be used multiple times!
2. The URL is Reusable
Unlike regular authentication URLs that expire after one use, static token URLs can be:
- Scanned multiple times
- Printed and distributed
- Embedded in physical objects
- Used by different users
3. Track Request Origin
function handleLocationAuth(location: string, userPubkey: string) {
// Parse location from static token
const [type, id, venue] = location.split('-');
switch (type) {
case 'table':
console.log(`User at table ${id} in ${venue}`);
// Send menu, track orders by table
break;
case 'door':
console.log(`Access request at ${id}`);
// Check permissions, unlock door
break;
case 'kiosk':
console.log(`Kiosk ${id} authentication`);
// Load user preferences
break;
}
}
Use Case: Restaurant Tables
Setup
class RestaurantService {
private client: PortalSDK;
private tableUrls = new Map<number, string>();
async generateTableQRCodes(tableCount: number) {
const urls: Array<{ table: number; url: string }> = [];
for (let tableNum = 1; tableNum <= tableCount; tableNum++) {
const staticToken = `table-${tableNum}-myrestaurant`;
const authUrl = await this.client.newKeyHandshakeUrl(
async (mainKey) => {
console.log(`Table ${tableNum}: User ${mainKey} authenticated`);
// Authenticate user
const authResponse = await this.client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Associate user with table
this.assignUserToTable(tableNum, mainKey);
// Send digital menu
this.sendMenu(mainKey);
}
},
staticToken
);
this.tableUrls.set(tableNum, authUrl);
urls.push({ table: tableNum, url: authUrl });
}
return urls;
}
async requestTablePayment(tableNum: number, amount: number) {
const userPubkey = this.getTableUser(tableNum);
if (!userPubkey) {
throw new Error('No user at this table');
}
return new Promise((resolve) => {
this.client.requestSinglePayment(
userPubkey,
[],
{
amount: amount * 1000,
currency: Currency.Millisats,
description: `Payment for Table ${tableNum}`
},
(status) => {
if (status.status === 'paid') {
console.log(`Table ${tableNum} paid!`);
this.clearTable(tableNum);
resolve(true);
}
}
);
});
}
private assignUserToTable(table: number, pubkey: string) {
// Implementation...
}
private getTableUser(table: number): string | null {
// Implementation...
return null;
}
private clearTable(table: number) {
// Implementation...
}
private sendMenu(pubkey: string) {
// Send menu items via Nostr direct messages
}
}
// Usage
const restaurant = new RestaurantService();
// Generate QR codes for 20 tables
const qrCodes = await restaurant.generateTableQRCodes(20);
// Print QR codes
for (const { table, url } of qrCodes) {
console.log(`Table ${table}:`);
await generateQRCodeImage(url, `table-${table}.png`);
}
// Later, when bill is ready
await restaurant.requestTablePayment(14, 45); // Table 14, 45 sats
Generating QR Codes
import QRCode from 'qrcode';
import fs from 'fs';
async function generateQRCodeImage(url: string, filename: string) {
// Generate PNG
await QRCode.toFile(filename, url, {
width: 400,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
console.log(`QR code saved: ${filename}`);
}
async function generateQRCodeSVG(url: string, filename: string) {
// Generate SVG for print quality
const svg = await QRCode.toString(url, { type: 'svg' });
fs.writeFileSync(filename, svg);
console.log(`QR code saved: ${filename}`);
}
// Generate for all tables
const tableUrl = await client.newKeyHandshakeUrl(handler, 'table-1');
await generateQRCodeImage(tableUrl, 'table-1-qr.png');
await generateQRCodeSVG(tableUrl, 'table-1-qr.svg'); // For printing
Use Case: Office Door Access
class DoorAccessSystem {
private client: PortalSDK;
private authorizedUsers = new Set<string>();
async setupDoor(doorId: string) {
const staticToken = `door-${doorId}`;
const nfcUrl = await this.client.newKeyHandshakeUrl(
async (mainKey) => {
console.log(`Access attempt at ${doorId} by ${mainKey}`);
// Authenticate user
const authResponse = await this.client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Check if user has access
if (this.authorizedUsers.has(mainKey)) {
console.log('✅ Access granted');
this.unlockDoor(doorId);
this.logAccess(doorId, mainKey, 'granted');
} else {
console.log('❌ Access denied - not authorized');
this.logAccess(doorId, mainKey, 'denied');
}
}
},
staticToken
);
console.log(`Write this URL to NFC sticker for ${doorId}:`);
console.log(nfcUrl);
return nfcUrl;
}
addAuthorizedUser(pubkey: string) {
this.authorizedUsers.add(pubkey);
}
removeAuthorizedUser(pubkey: string) {
this.authorizedUsers.delete(pubkey);
}
private unlockDoor(doorId: string) {
// Send signal to smart lock
console.log(`Door ${doorId} unlocked for 5 seconds`);
}
private logAccess(doorId: string, user: string, result: 'granted' | 'denied') {
const log = {
timestamp: new Date(),
door: doorId,
user,
result
};
// Store in database
console.log('Access log:', log);
}
}
// Setup
const doorSystem = new DoorAccessSystem();
// Generate NFC URLs for different doors
await doorSystem.setupDoor('main-entrance');
await doorSystem.setupDoor('server-room');
await doorSystem.setupDoor('executive-office');
// Authorize users
doorSystem.addAuthorizedUser('user-pubkey-1'); // Main entrance
doorSystem.addAuthorizedUser('user-pubkey-2'); // All doors
Use Case: Event Entrances
Track which entrance each guest uses:
class EventCheckIn {
private client: PortalSDK;
private checkedInGuests = new Map<string, string>(); // pubkey -> entrance
async setupEntrances(entrances: string[]) {
const urls: Map<string, string> = new Map();
for (const entrance of entrances) {
const staticToken = `entrance-${entrance}`;
const url = await this.client.newKeyHandshakeUrl(
async (mainKey) => {
console.log(`Guest ${mainKey} at ${entrance}`);
const authResponse = await this.client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Check in guest
this.checkedInGuests.set(mainKey, entrance);
// Request ticket payment if needed
await this.verifyTicket(mainKey);
console.log(`✅ Checked in at ${entrance}`);
}
},
staticToken
);
urls.set(entrance, url);
}
return urls;
}
async verifyTicket(userPubkey: string) {
// Request Cashu ticket token
const result = await this.client.requestCashu(
userPubkey,
[],
'https://mint.example.com',
'vip',
1
);
if (result.status === 'success') {
// Burn to verify
await this.client.burnCashu(
'https://mint.example.com',
'vip',
result.token
);
return true;
}
return false;
}
getEntranceStats() {
const stats = new Map<string, number>();
for (const entrance of this.checkedInGuests.values()) {
stats.set(entrance, (stats.get(entrance) || 0) + 1);
}
return stats;
}
}
// Usage
const event = new EventCheckIn();
const entranceUrls = await event.setupEntrances([
'main-entrance',
'vip-entrance',
'backstage'
]);
// Print QR codes for each entrance
for (const [entrance, url] of entranceUrls) {
await generateQRCodeImage(url, `${entrance}-checkin.png`);
}
// Later: view stats
console.log('Check-in stats:', event.getEntranceStats());
// { 'main-entrance': 145, 'vip-entrance': 23, 'backstage': 8 }
NFC Integration Concepts
While Portal SDK doesn't directly handle NFC hardware, you can integrate with NFC-capable apps:
Writing to NFC
- Generate URL with static token
- Use NFC writing app to write the URL as an NDEF record
- Place sticker at physical location
Reading from NFC (Mobile App)
When a user's Nostr-compatible wallet app supports NFC:
- User taps phone on NFC sticker
- App reads the Portal authentication URL
- App opens the authentication flow
- User approves authentication
- Your backend receives the callback with the static token
- You know which physical location they're at
Example NFC Data Format
NDEF Record:
Type: URI
Data: nostr:nprofile1[...]static-token=table-5
Location-Based Routing
Use static tokens to route requests differently:
const locationHandlers = {
'table-': (token: string, user: string) => {
const tableNum = token.split('-')[1];
return handleRestaurantTable(tableNum, user);
},
'door-': (token: string, user: string) => {
const doorId = token.split('-')[1];
return handleDoorAccess(doorId, user);
},
'kiosk-': (token: string, user: string) => {
const kioskId = token.split('-')[1];
return handleKioskAuth(kioskId, user);
}
};
async function handleStaticTokenAuth(staticToken: string, userPubkey: string) {
// Find handler based on token prefix
for (const [prefix, handler] of Object.entries(locationHandlers)) {
if (staticToken.startsWith(prefix)) {
return handler(staticToken, userPubkey);
}
}
// Default handler
return handleGenericAuth(userPubkey);
}
// Generate URLs with routing
const tableUrl = await client.newKeyHandshakeUrl(
(mainKey) => handleStaticTokenAuth('table-5', mainKey),
'table-5'
);
const doorUrl = await client.newKeyHandshakeUrl(
(mainKey) => handleStaticTokenAuth('door-main', mainKey),
'door-main'
);
Security Considerations
1. Static Token Entropy
Use sufficiently random static tokens:
import crypto from 'crypto';
function generateStaticToken(prefix: string): string {
const random = crypto.randomBytes(16).toString('hex');
return `${prefix}-${random}`;
}
// Good: table-5-a3f9d2e1c4b8...
const token = generateStaticToken('table-5');
2. Token Rotation
Periodically rotate static tokens for sensitive locations:
class TokenManager {
private activeTokens = new Map<string, Date>();
async rotateToken(location: string, oldToken: string) {
const newToken = generateStaticToken(location);
// Generate new URL
const newUrl = await client.newKeyHandshakeUrl(
handler,
newToken
);
// Mark old token as deprecated
this.activeTokens.set(newToken, new Date());
// Give grace period before removing old
setTimeout(() => {
this.activeTokens.delete(oldToken);
}, 86400000); // 24 hours
return { token: newToken, url: newUrl };
}
}
3. Physical Security
- QR Codes: Consider using tamper-evident materials
- NFC Stickers: Use stickers with tamper detection
- Location: Place in supervised areas when possible
- Monitoring: Log all authentication attempts with timestamps
4. Access Control
Verify user permissions based on location:
const permissions = {
'table-1': ['menu', 'order', 'payment'],
'door-serverroom': ['authenticated-staff-only'],
'kiosk-lobby': ['check-in', 'directions']
};
async function checkPermission(staticToken: string, userPubkey: string, action: string) {
const requiredPerms = permissions[staticToken] || [];
const userPerms = await getUserPermissions(userPubkey);
return requiredPerms.some(perm => userPerms.includes(perm));
}
Analytics & Insights
Track physical location usage:
class LocationAnalytics {
private events: Array<{
timestamp: Date;
location: string;
user: string;
action: string;
}> = [];
logEvent(location: string, user: string, action: string) {
this.events.push({
timestamp: new Date(),
location,
user,
action
});
}
getLocationStats(timeframe: 'hour' | 'day' | 'week') {
// Aggregate by location
const stats = new Map<string, number>();
for (const event of this.events) {
stats.set(event.location, (stats.get(event.location) || 0) + 1);
}
return stats;
}
getPeakTimes(location: string) {
const hourCounts = new Array(24).fill(0);
for (const event of this.events) {
if (event.location === location) {
const hour = event.timestamp.getHours();
hourCounts[hour]++;
}
}
return hourCounts;
}
}
// Usage
const analytics = new LocationAnalytics();
// In your handler
const url = await client.newKeyHandshakeUrl(
(mainKey) => {
analytics.logEvent('table-5', mainKey, 'authenticated');
// ...rest of handler
},
'table-5'
);
// Later: analyze
console.log('Busiest tables:', analytics.getLocationStats('day'));
console.log('Table 5 peak hours:', analytics.getPeakTimes('table-5'));
Best Practices
1. Naming Conventions
Use consistent token naming:
// Good patterns:
'table-{number}-{venue}' // table-14-downtown
'door-{building}-{room}' // door-hq-serverroom
'kiosk-{location}-{number}' // kiosk-lobby-1
'entrance-{event}-{gate}' // entrance-concert-a
2. QR Code Printing
For physical QR codes:
await QRCode.toFile('table-5.png', url, {
width: 600, // Large enough to scan easily
margin: 4, // White border for reliability
errorCorrectionLevel: 'H' // High redundancy for damaged codes
});
3. Fallback Mechanisms
Provide alternatives if scanning fails:
// Include short URL or manual code
const shortCode = generateShortCode(staticToken);
console.log(`QR Code URL: ${url}`);
console.log(`Manual Code: ${shortCode}`);
// User can type: PORTALR5X9
4. Testing
Test all physical touchpoints:
# Generate test QR code
node scripts/generate-qr.js table-test
# Scan with multiple devices
# - iPhone with Nostr wallet
# - Android with Nostr wallet
# - Verify callback received
Why This Matters
Static tokens enable Portal to bridge the digital and physical worlds:
🌐 Online → Traditional web/mobile authentication 🏪 In-Person → QR codes, NFC, physical authentication
This makes Portal unique: one protocol, infinite touchpoints.
Whether someone is browsing your website or sitting at your restaurant, they authenticate the same way with the same identity—their Nostr key.
Next Steps:
- Authentication Flow - Core authentication concepts
- Cashu Tokens - Physical tickets with Cashu
- Single Payments - Process payments from physical locations