Ave’s device approval flow lets you sign in on new devices by approving the request from a device you’re already logged into. The master key is securely transferred using ephemeral key exchange.
How It Works
When you try to log in on a new device without a passkey:
Request approval
The new device generates an ephemeral ECDH key pair and sends a login request to Ave with the public key.
Real-time notification
Your trusted devices receive a WebSocket notification instantly. A badge appears on the “Login Requests” menu.
Review request
You see the requesting device’s name, browser, OS, IP address, and timestamp. You can approve or deny.
Secure key transfer
If approved, your trusted device encrypts the master key using ECDH (Elliptic Curve Diffie-Hellman) and sends it to Ave.
Automatic login
The new device receives the encrypted master key, decrypts it with its private key, and logs in automatically.
Creating a Login Request
From the login page, select “Confirm on a trusted device”:
// POST /api/login/request-approval
{
"handle" : "alice" ,
"requesterPublicKey" : "BEhT3l8Y2...base64-encoded-key" ,
"device" : {
"name" : "Chrome on MacBook" ,
"type" : "computer" ,
"browser" : "Chrome 120" ,
"os" : "macOS 14.2" ,
"fingerprint" : "abc123def456" // Browser fingerprint
}
}
From login.ts:318-405, the server:
Validates the handle exists
Creates a loginRequests record with:
Device metadata (name, type, browser, OS)
Requester’s ephemeral public key
IP address and fingerprint
5-minute expiration timestamp
Sends WebSocket notifications to all active devices
Sends push notifications to devices with subscriptions
// From login.ts:360-367
notifyLoginRequest ( handle . toLowerCase (), {
id: request . id ,
deviceName: request . deviceName ,
deviceType: request . deviceType ,
browser: request . browser ,
os: request . os ,
ipAddress: request . ipAddress ,
});
Login requests expire after 5 minutes for security. If not approved or denied within this window, the request is automatically expired.
Real-Time Notifications
Ave uses WebSocket for instant notifications:
// Client connects to WebSocket when logged in
const ws = new WebSocket ( 'wss://api.aveid.net/ws' );
ws . onopen = () => {
ws . send ( JSON . stringify ({ type: 'auth' , token: sessionToken }));
};
ws . onmessage = ( event ) => {
const message = JSON . parse ( event . data );
if ( message . type === 'login_request' ) {
// Show notification badge
updateLoginRequestsBadge ( message . data );
}
};
The WebSocket connection:
Authenticates using your session token
Subscribes to events for your handle
Receives login requests, approvals, and denials in real-time
Automatically reconnects if disconnected
Push Notifications
Devices can also receive web push notifications :
// From login.ts:375-399
for ( const userDevice of userDevices ) {
if ( userDevice . pushSubscription ) {
const sent = await sendLoginRequestNotification ( subscription , {
requestId: request . id ,
deviceName: request . deviceName || "Unknown Device" ,
deviceType: request . deviceType || "computer" ,
browser: request . browser || undefined ,
os: request . os || undefined ,
ipAddress: request . ipAddress || undefined ,
});
// Remove invalid subscriptions
if ( ! sent ) {
await db . update ( devices )
. set ({ pushSubscription: null })
. where ( eq ( devices . id , userDevice . id ));
}
}
}
Push notifications work even when Ave is closed, ensuring you never miss a login request.
Approving Requests
From your trusted device, navigate to “Login Requests” to see pending requests:
// GET /api/login-requests (from authenticated endpoint)
// Returns all pending requests for your identities
{
"requests" : [
{
"id" : "req_123" ,
"deviceName" : "Chrome on MacBook" ,
"deviceType" : "computer" ,
"browser" : "Chrome 120" ,
"os" : "macOS 14.2" ,
"ipAddress" : "192.168.1.10" ,
"createdAt" : "2024-01-01T12:00:00Z" ,
"expiresAt" : "2024-01-01T12:05:00Z"
}
]
}
To approve:
// POST /api/login-requests/:id/approve
{
"encryptedMasterKey" : "base64-encrypted-master-key" ,
"approverPublicKey" : "base64-ephemeral-public-key"
}
Ephemeral Key Exchange
The approving device generates its own ephemeral key pair:
// Generate approver's ephemeral key pair
const approverKeyPair = await crypto . subtle . generateKey (
{ name: 'ECDH' , namedCurve: 'P-256' },
true ,
[ 'deriveKey' ]
);
// Load requester's public key (from login request)
const requesterPublicKey = await crypto . subtle . importKey (
'raw' ,
base64ToArrayBuffer ( request . requesterPublicKey ),
{ name: 'ECDH' , namedCurve: 'P-256' },
false ,
[]
);
// Derive shared secret using ECDH
const sharedSecret = await crypto . subtle . deriveKey (
{ name: 'ECDH' , public: requesterPublicKey },
approverKeyPair . privateKey ,
{ name: 'AES-GCM' , length: 256 },
false ,
[ 'encrypt' ]
);
// Encrypt master key with shared secret
const iv = crypto . getRandomValues ( new Uint8Array ( 12 ));
const masterKeyBytes = base64ToArrayBuffer ( localStorage . getItem ( 'ave_master_key' ));
const encryptedMasterKey = await crypto . subtle . encrypt (
{ name: 'AES-GCM' , iv },
sharedSecret ,
masterKeyBytes
);
// Send to Ave (server cannot decrypt)
await fetch ( `/api/login-requests/ ${ requestId } /approve` , {
method: 'POST' ,
body: JSON . stringify ({
encryptedMasterKey: arrayBufferToBase64 ( encryptedMasterKey ),
approverPublicKey: await exportPublicKey ( approverKeyPair . publicKey ),
iv: arrayBufferToBase64 ( iv )
})
});
From login.ts:426-504, when approved:
Ave updates the request with encryptedMasterKey and approverPublicKey
Creates a new session for the requesting device
Notifies the requester via WebSocket
Returns session token and encrypted master key
return c . json ({
status: "approved" ,
sessionToken ,
encryptedMasterKey: request . encryptedMasterKey ,
approverPublicKey: request . approverPublicKey ,
device: { id , name , type },
identities: [ ... ]
});
The shared secret is derived independently by both devices using ECDH. Ave never sees the unencrypted master key or the shared secret.
Polling for Status
If WebSocket is unavailable, the requesting device polls for status:
// GET /api/login/request-status/:requestId
// Polls every 2 seconds
setInterval ( async () => {
const response = await fetch ( `/api/login/request-status/ ${ requestId } ` );
const data = await response . json ();
if ( data . status === 'approved' ) {
// Decrypt master key and log in
await handleApproval ( data );
} else if ( data . status === 'denied' ) {
showError ( 'Request denied by user' );
} else if ( data . status === 'expired' ) {
showError ( 'Request expired. Please try again.' );
}
}, 2000 );
From login.ts:407-514, the polling endpoint:
Returns current status (pending, approved, denied, expired)
Automatically marks expired requests
Creates session when first polled after approval
Deletes request after successful login
Decrypting on New Device
When the request is approved, the new device receives:
{
"status" : "approved" ,
"sessionToken" : "abc123..." ,
"encryptedMasterKey" : "base64-encrypted-data" ,
"approverPublicKey" : "base64-public-key"
}
The new device decrypts the master key:
// Load approver's public key
const approverPublicKey = await crypto . subtle . importKey (
'raw' ,
base64ToArrayBuffer ( response . approverPublicKey ),
{ name: 'ECDH' , namedCurve: 'P-256' },
false ,
[]
);
// Derive same shared secret using our private key
const sharedSecret = await crypto . subtle . deriveKey (
{ name: 'ECDH' , public: approverPublicKey },
requesterKeyPair . privateKey , // Our ephemeral private key
{ name: 'AES-GCM' , length: 256 },
false ,
[ 'decrypt' ]
);
// Decrypt master key
const masterKeyBytes = await crypto . subtle . decrypt (
{ name: 'AES-GCM' , iv },
sharedSecret ,
base64ToArrayBuffer ( response . encryptedMasterKey )
);
// Store in localStorage
const masterKeyB64 = arrayBufferToBase64 ( masterKeyBytes );
localStorage . setItem ( 'ave_master_key' , masterKeyB64 );
// Save session token
localStorage . setItem ( 'ave_session' , response . sessionToken );
// Redirect to dashboard
window . location . href = '/dashboard' ;
The ephemeral key pairs are discarded immediately after the exchange. Even if Ave’s servers are compromised, past master key transfers cannot be decrypted.
Denying Requests
To deny a login request:
// POST /api/login-requests/:id/deny
await fetch ( `/api/login-requests/ ${ requestId } /deny` , {
method: 'POST'
});
The request is marked as denied and deleted. The requesting device is notified via WebSocket or polling.
Device Fingerprinting
Ave uses browser fingerprinting to recognize returning devices:
// Client generates fingerprint
const fingerprint = await generateFingerprint ({
canvas: true ,
webgl: true ,
audio: false // Privacy-friendly
});
// Include in device info
const device = {
name: 'Chrome on MacBook' ,
type: 'computer' ,
fingerprint: fingerprint // Unique ID for this browser
};
From login.ts:29-88, when logging in:
if ( deviceInfo . fingerprint ) {
const [ existingDevice ] = await db
. select ()
. from ( devices )
. where ( and (
eq ( devices . userId , userId ),
eq ( devices . fingerprint , deviceInfo . fingerprint )
));
if ( existingDevice ) {
// Reuse existing device, update metadata
return { id: existingDevice . id , isNew: false };
}
}
Benefits:
Fewer device records in your account
Accurate “last seen” timestamps
Better security insights (see if unknown devices logged in)
Device fingerprints can change if you:
Clear browser data
Update browser or OS
Change screen resolution
Disable JavaScript APIs
This is expected and will create a new device entry.
Security Considerations
Request Expiration
Login requests expire after 5 minutes :
// From login.ts:354
expiresAt : new Date ( Date . now () + 5 * 60 * 1000 )
This prevents:
Stale requests from being approved hours later
Attackers from waiting for you to accidentally approve old requests
IP Address Logging
Ave logs the IP address of the requesting device:
// From login.ts:353
ipAddress : c . req . header ( "x-forwarded-for" ) || c . req . header ( "x-real-ip" )
You can see if the request is from:
Same network (home/office)
Different city/country
Known VPN/proxy
Activity Logging
Both approval and denial are logged:
// From login.ts:466-475
await db . insert ( activityLogs ). values ({
userId: identity . userId ,
action: "login" ,
details: {
method: "device_approval" ,
deviceName: deviceRecord . name ,
isNewDevice: deviceRecord . isNew
},
deviceId: deviceRecord . id ,
severity: "info"
});
You can review all login approvals in your Activity Log.
Fallback Methods
If device approval fails or times out, you can:
Use a passkey if you have one registered on the new device
Enter a trust code to recover your master key
Contact support if you’ve lost all access (account may be unrecoverable)
Testing the Flow
From TESTING.md:156-297, to test multi-device login:
Device 1 : Log in normally and keep the window open
Device 2 : Open an incognito window, enter your handle
Device 2 : Click “Confirm on a trusted device”
Device 1 : See the notification badge, navigate to “Login Requests”
Device 1 : Review the request details and click “Approve”
Device 2 : Automatically logged in within 1-2 seconds
Test the denial flow by clicking “Deny” instead of “Approve”. The requesting device should show “Request denied” immediately.
Next Steps
Passwordless Auth Learn about passkey authentication
End-to-End Encryption Understand how master keys are encrypted