Architecture overview
Ave is built on three core principles:
Passwordless authentication - WebAuthn passkeys replace passwords
End-to-end encryption - Master keys never leave devices in plaintext
Standards-based - OAuth 2.0, OIDC, and WebAuthn protocols
Components
Frontend (ave-web)
React + Vite application that handles:
User registration and login UI
WebAuthn credential creation/authentication
Client-side cryptography (master key generation, encryption)
OAuth authorization flows
Device management dashboard
Backend (ave-server)
Hono.js API running on Cloudflare Workers:
WebAuthn registration/authentication endpoints (/api/register, /api/login)
OAuth 2.0 token endpoints (/api/oauth/token, /api/oauth/authorize)
OIDC discovery (/.well-known/openid-configuration)
Session management with stateless JWT tokens
WebSocket server for real-time notifications
SDKs
@ave-id/sdk - TypeScript SDK with helpers for:
Building authorization URLs with PKCE
Exchanging codes for tokens
Refreshing access tokens
Token exchange (delegation)
Signing API integration
@ave-id/embed - Embeddable authentication UI:
Inline iframes
Modal sheets
Popup windows
PostMessage communication
End-to-end encryption
Master key generation
When a user creates an account, Ave generates a 256-bit master key in the browser using crypto.getRandomValues(). This key never leaves the device in plaintext.
// Simplified from ave-web/src/lib/crypto.ts
function generateMasterKey () : Uint8Array {
const key = new Uint8Array ( 32 );
crypto . getRandomValues ( key );
return key ;
}
The master key is never sent to the server . All encryption/decryption happens client-side in the browser.
Master key protection
The master key is protected by three independent methods:
Passkeys (PRF)
Security questions
Trust codes
Ave uses the PRF (Pseudo-Random Function) extension of WebAuthn to derive encryption keys from passkey authentication. // During registration
const credential = await navigator . credentials . create ({
publicKey: {
... options ,
extensions: {
prf: { eval: { first: salt } },
},
},
});
const prfOutput = credential . getClientExtensionResults (). prf . results . first ;
const encryptionKey = await deriveKey ( prfOutput );
const encryptedMasterKey = await encrypt ( masterKey , encryptionKey );
PRF provides a unique, deterministic output for each passkey. The encrypted master key is stored in localStorage, and decrypted when the user authenticates.
Users select 3 security questions and provide answers. Ave hashes the answers using PBKDF2 and uses them to encrypt the master key. // Hash each answer
const hashedAnswers = await Promise . all (
answers . map ( answer =>
pbkdf2 ( answer . toLowerCase (). trim (), salt , 100000 , 32 )
)
);
// Derive encryption key from hashed answers
const combinedKey = hashAnswers . reduce (( acc , hash ) => xor ( acc , hash ));
const encryptedMasterKey = await encrypt ( masterKey , combinedKey );
The server stores:
Hashed answers (for verification)
Encrypted master key
Salt
Answers are case-insensitive and trimmed before hashing. Users must provide exact answers to recover their master key.
Ave generates 2 single-use trust codes in the format XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (25 characters, base32 encoded). // Generate trust code
const bytes = new Uint8Array ( 20 );
crypto . getRandomValues ( bytes );
const code = base32Encode ( bytes ). match ( / . {1,5} / g ). join ( "-" );
// Encrypt master key with trust code
const key = await deriveKeyFromCode ( code );
const encryptedMasterKey = await encrypt ( masterKey , key );
The server stores:
Hashed trust code (bcrypt)
Encrypted master key
Usage status
Trust codes are single-use . After using a code to recover access, it’s marked as used and cannot be used again. Users should regenerate new codes after recovery.
Data encryption
All user data (profile fields, settings, etc.) is encrypted with the master key before being sent to the server:
const encryptedData = await encryptWithMasterKey ( masterKey , {
displayName: "Jane Doe" ,
email: "[email protected] " ,
birthday: "1990-01-01" ,
});
// Server only receives encrypted blob
await fetch ( "/api/identities" , {
method: "POST" ,
body: JSON . stringify ({ encryptedData }),
});
The server has zero knowledge of user data. It can only store and retrieve encrypted blobs.
Authentication flows
Registration flow
User enters handle
Frontend checks if handle is available via /api/register/start
WebAuthn registration
Browser prompts for biometric/PIN to create a passkey. A temporary user ID is generated for the WebAuthn credential. const options = await generateRegistrationOptions ({
rpName: "Ave" ,
rpID: "aveid.net" ,
userName: handle ,
userID: tempUserId ,
authenticatorSelection: {
residentKey: "required" ,
userVerification: "required" ,
},
extensions: {
prf: { eval: { first: salt } },
},
});
Master key generation
Frontend generates a 256-bit master key and encrypts it with PRF output, security question answers, and trust codes.
Complete registration
Frontend sends to /api/register/complete:
WebAuthn credential (public key, credential ID)
Identity data (encrypted)
Hashed security answers
Encrypted master key backups
Trust codes (hashed)
Device information
Backend creates:
User account
Identity record
Passkey record
Device record
Session token
User logged in
Session token is set in an HTTP-only cookie. User is redirected to the dashboard.
Passkey login flow
User enters handle
Frontend calls /api/login/start to get WebAuthn challenge options.
WebAuthn authentication
Browser prompts for biometric/PIN. The passkey signs the challenge. const credential = await navigator . credentials . get ({
publicKey: {
... options ,
extensions: {
prf: { eval: { first: salt } },
},
},
});
Decrypt master key
Frontend uses PRF output to decrypt the master key from localStorage.
Complete login
Frontend sends signed challenge to /api/login/complete. Backend:
Verifies WebAuthn signature
Creates session token
Updates device last seen timestamp
Multi-device login flow
When a user wants to log in on a new device without a passkey:
Request approval
New device generates an ephemeral ECDH key pair (P-256 curve) and sends public key to /api/login/request-approval. const keyPair = await crypto . subtle . generateKey (
{ name: "ECDH" , namedCurve: "P-256" },
true ,
[ "deriveKey" ]
);
const publicKeyJwk = await crypto . subtle . exportKey ( "jwk" , keyPair . publicKey );
Backend stores the login request with the requester’s public key.
Notify trusted device
Backend sends WebSocket notification to all active devices for this user. Trusted device sees the login request in real-time.
User approves
Trusted device:
Loads master key from localStorage
Generates its own ephemeral ECDH key pair
Derives shared secret using requester’s public key
Encrypts master key with shared secret
Sends encrypted master key to /api/login/approve
const sharedSecret = await crypto . subtle . deriveKey (
{ name: "ECDH" , public: requesterPublicKey },
trustedDevicePrivateKey ,
{ name: "AES-GCM" , length: 256 },
false ,
[ "encrypt" ]
);
const encryptedMasterKey = await crypto . subtle . encrypt (
{ name: "AES-GCM" , iv },
sharedSecret ,
masterKey
);
New device receives master key
New device:
Receives encrypted master key via WebSocket
Derives same shared secret using trusted device’s ephemeral public key
Decrypts master key
Stores in localStorage
Logs in automatically
The ephemeral ECDH keys are never stored - they’re generated on-demand and discarded after use. The backend never sees the shared secret or plaintext master key.
OAuth 2.0 integration
Authorization flow
App redirects to Ave
const url = buildAuthorizeUrl ({
clientId: "app_123" ,
redirectUri: "https://yourapp.com/callback" ,
}, {
scope: [ "openid" , "profile" , "email" ],
codeChallenge: challenge ,
codeChallengeMethod: "S256" ,
});
// Redirects to: https://aveid.net/signin?client_id=...
User authenticates
User logs in with passkey and selects which identity to share with the app.
User authorizes app
Ave shows app information (name, description, website) and requested permissions. User swipes to approve.
Redirect with code
Ave redirects back to app with authorization code: https://yourapp.com/callback?code=abc123&state=xyz
Exchange code for tokens
App backend exchanges code for tokens: const tokens = await exchangeCodeServer (
{
clientId: "app_123" ,
clientSecret: process . env . AVE_SECRET ,
redirectUri: "https://yourapp.com/callback" ,
},
{ code: "abc123" }
);
Backend verifies PKCE code_verifier (if used) and returns:
access_token - JWT with user/identity info
id_token - OIDC identity token
refresh_token - Long-lived token (if offline_access requested)
Token structure
Access tokens are Ed25519-signed JWTs with the following claims:
{
"sub" : "user_abc123" ,
"iss" : "https://api.aveid.net" ,
"aud" : "app_xyz789" ,
"iat" : 1234567890 ,
"exp" : 1234571490 ,
"scope" : "openid profile email" ,
"identity_id" : "identity_def456" ,
"handle" : "janedoe" ,
"name" : "Jane Doe" ,
"email" : "[email protected] " ,
"avatar_url" : "https://cdn.aveid.net/avatars/..."
}
Verify tokens using the public key from /.well-known/jwks.json:
import * as jose from "jose" ;
const jwks = await jose . createRemoteJWKSet (
new URL ( "https://api.aveid.net/.well-known/jwks.json" )
);
const { payload } = await jose . jwtVerify ( accessToken , jwks , {
issuer: "https://api.aveid.net" ,
audience: "your_client_id" ,
});
Security features
Session management
Sessions are stateless JWT tokens signed with Ed25519. They contain:
User ID
Device ID
Issued at / Expires at timestamps
Session fingerprint (hashed)
Sessions are stored in HTTP-only, secure, SameSite=Lax cookies.
Session tokens are short-lived (default: 7 days) and automatically refreshed on activity.
Device tracking
Each device has a unique fingerprint stored in localStorage:
const fingerprint = ` ${ navigator . userAgent } _ ${ screen . width } x ${ screen . height } _ ${ timezone } ` ;
Devices are tracked with:
Device type (phone, computer, tablet)
Browser and OS information
Last seen timestamp
IP address (logged in activity)
Activity logging
All security-relevant actions are logged:
Account creation
Login/logout events
Passkey added/removed
Trust codes regenerated
Device revoked
OAuth authorizations
Logs include:
Timestamp
Action type
Device ID
IP address
User agent
Severity (info, warning, danger)
Rate limiting
Ave implements rate limiting on sensitive endpoints:
Login attempts: 5 per minute per IP
Registration: 3 per hour per IP
Trust code recovery: 3 per hour per account
Security question attempts: 5 per hour per account
Rate limits are enforced at the Cloudflare Workers level using Durable Objects for distributed state.
OIDC discovery
Ave exposes standard OIDC discovery endpoints:
curl https://api.aveid.net/.well-known/openid-configuration
{
"issuer" : "https://api.aveid.net" ,
"authorization_endpoint" : "https://api.aveid.net/api/oauth/authorize" ,
"token_endpoint" : "https://api.aveid.net/api/oauth/token" ,
"userinfo_endpoint" : "https://api.aveid.net/api/oauth/userinfo" ,
"jwks_uri" : "https://api.aveid.net/.well-known/jwks.json" ,
"scopes_supported" : [ "openid" , "profile" , "email" , "offline_access" ],
"response_types_supported" : [ "code" ],
"grant_types_supported" : [
"authorization_code" ,
"refresh_token" ,
"urn:ietf:params:oauth:grant-type:token-exchange"
],
"subject_types_supported" : [ "public" ],
"id_token_signing_alg_values_supported" : [ "EdDSA" ],
"token_endpoint_auth_methods_supported" : [ "client_secret_post" , "none" ]
}
Next steps
Signing API Request cryptographic signatures from users
Delegation Implement token exchange and connector apps
Self-hosting Deploy your own Ave instance
Testing guide Complete testing walkthrough with all flows