Local First Auth Specification
Overview
The Local First Auth Spec defines how a Local First Auth app (an iOS or Android mobile app) communicates with third-party web applications (mini apps). More specifically, when a user scans a QR code using a Local First Auth app, this spec defines how their profile and other data get securely passed between the app and the mini app. Antler is a demo app that showcases how apps can integrate this spec.
User Benefits
When a user downloads a Local First Auth app (like Antler, a demo implementation), they create a profile that is stored locally on their device. Whenever a user scans a QR code, their profile gets shared with the mini app. This means users don't have to go through account creation and immediately get logged in.
Developer Benefits
The benefit of integrating with the Local First Auth spec is it transforms a regular QR code and allows you to:
- Skip auth – no auth systems, no user management, no password resets
- Instant UX – users scan and start using your app immediately
- Deploy a website – no app store submissions, no native code, no review process
There will always be a need for native mobile apps. Mini apps fill a gap where building and maintaining a native app doesn't make sense e.g.) social clubs, local community events, venues, pop-ups, game nights with friends, or any lightweight gathering where people are physically present.
Lifecycle
1. User scans QR code using a Local First Auth app
2. App loads URL in WebView
3. App injects window.localFirstAuth JavaScript object
4. Mini app calls window.localFirstAuth.getProfileDetails() when ready
5. App generates and signs JWT with profile details
6. Mini app verifies JWT & has access to profile details
// Fetches Local First Auth Manifest in the background
7. App parses HTML for <link rel="local-first-auth-manifest"> tag
8. App fetches manifest in background
// If you require additional permissions at a later time
9. Mini app calls window.localFirstAuth.requestPermission('location')
10. App validates permission is declared in manifest
11. If declared → App shows user consent prompt
12. If NOT declared → request is rejected (security)
13. If user approves → App sends location data via postMessage
Local First Auth Manifest
Every mini app has a manifest file. The purpose is to showcase basic details about the mini app and explicitly state which permissions your mini app needs.
Discovery
Mini apps declare their manifest using a <link> tag in the HTML <head>.
<link rel="local-first-auth-manifest" href="/local-first-auth-manifest.json">
manifest.json Schema
{
"name": "Coffee Shop",
"description": "Cozy little bakery and coffee shop",
"location": "123 Davie Street, Vancouver, BC",
"icon": "https://yourdomain.com/icon.png",
"type": "place",
"permissions": ["profile"] //profile is granted by default
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Display name of the mini app |
description |
string | No | Short description of the mini app |
location |
string | No | Location of the experience |
icon |
string (URL) | No | App icon URL (recommended: 512x512px). Note: You can use an absolute url or a relative path like ./icon.png (which resolves to https://yourdomain.com/icon.png) |
type |
string | No | Context type: "place", "event", "club", etc. |
permissions |
array | No | Requested permissions. "profile" is granted by default. |
Note: Currently, this spec just supports the 'profile' permission. However, Local First Auth apps are designed to be native containers that pass data to 3rd party mini apps. In the future, additional native capabilities could be exposed e.g.) location, bluetooth, or push notifications (if user explicitly grants permission).
Decentralized Identifiers
When a user downloads a Local First Auth app, they create a profile on the app. Under the hood, each profile is a DID (Decentralized Identifier - a W3C standard) with additional details (like name, avatar, and links to socials).
A DID is a text string that is used to identify a user. Here's an example:

Local First Auth apps use the did:key method, where the public key is the last part of the DID.
When you create a profile on a Local First Auth app, your DID (which includes a public key) and a corresponding private key are generated and stored locally on your device. Whenever a Local First Auth app sends data to a mini app, the payload is signed using the DID's private key, ensuring it came from the DID owner.
JavaScript API
There are two ways Local First Auth apps and mini apps communicate:
window.localFirstAuth: Use when your mini app wants to request data or initiate actions (e.g., get profile details or request permissions)window.postMessage: Use when your mini app wants to be notified of events that happened in the Local First Auth app (e.g., user closed the WebView)
The window.localFirstAuth Object
When your mini app loads inside a Local First Auth app, a global window.localFirstAuth object is injected. This allows you to 1) call methods and get back data and 2) check that the user is using a Local First Auth app.
interface LocalFirstAuth {
// Get profile details (name, socials)
getProfileDetails(): Promise<string>;
// Get avatar as base64-encoded string
getAvatar(): Promise<string | null>;
// Get details about the Local First Auth host app
getAppDetails(): AppDetails;
// Request additional permissions (in the future)
requestPermission(permission: string): Promise<boolean>;
// Close the WebView (return to QR scanner)
close(): void;
}
Getting profile details
getProfileDetails() returns the user's profile details as a signed JWT.
{
"did": "did:key:123456789abcdefghi",
"name": "Danny Mathews",
"socials": [
{ "platform": "INSTAGRAM", "handle": "dmathewwws" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
did |
string | Yes | User's Decentralized Identifier (DID) |
name |
string | Yes | User's display name |
socials |
array | No | Links to social accounts |
For security reasons, always reconstruct social links client-side rather than trusting URLs. Check out this code.
Getting a user's avatar
getAvatar() returns the user's base64-encoded avatar as a signed JWT. This image can be up to 1MB in size. If the user has no avatar, this will return null.
{
"did": "did:key:123456789abcdefghi",
"avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD..."
}
| Field | Type | Required | Description |
|---|---|---|---|
did |
string | Yes | User's Decentralized Identifier (DID) |
avatar |
string | Yes | User's avatar as base64-encoded string |
Getting app details
getAppDetails() returns information about the Local First Auth app.
{
"name": "Antler",
"version": "1.0.0",
"platform": "ios",
"supportedPermissions": ["profile"]
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | App name |
version |
string | Yes | App version |
platform |
string | Yes | ios or android |
supportedPermissions |
array | Yes | The permissions that this app has implemented. |
Checking for a Local First Auth app
Your mini app can detect whether it's running inside a Local First Auth app or a regular web browser.
if (typeof window.localFirstAuth !== 'undefined') {
// Running in a Local First Auth app
const info = window.localFirstAuth.getAppDetails();
console.log(`Running in ${info.name} v${info.version}`);
} else {
// Regular web browser
console.log('Not in a Local First Auth app');
}
Use window.postMessage to receive data from Local First Auth app
A user may perform an action inside the Local First Auth app that you want to know about. The app sends event data to a mini app via window.postMessage using signed JWTs.
window.addEventListener('message', async (event) => {
try {
if (!event.data?.jwt) { return }
// verify JWT is valid
const payload = await decodeAndVerifyJWT(event.data.jwt);
// process message based on the type
switch (payload.type) {
case 'localFirstAuth:profile:disconnected':
const { type, ...profile } = payload.data;
console.log('User DID:', payload.iss);
console.log('User Name:', profile.name);
break;
default:
console.warn('Unknown message type:', payload.data.type);
}
} catch (error) {
console.error('Error processing message:', error);
}
});
Check out this example code if you want to add decodeAndVerifyJWT to your project.
Possible message types
| Type | Description | Required Permission |
|---|---|---|
localFirstAuth:profile:disconnected |
User closed WebView | profile |
localFirstAuth:error |
Error data | |
Profile Disconnected
localFirstAuth:profile:disconnected returns the same profile details mentioned above.
{
"did": "did:key:123456789abcdefghi",
"name": "Danny Mathews",
"socials": [
{ "platform": "INSTAGRAM", "handle": "dmathewwws" }
]
}
Error Handling
localFirstAuth:error returns errors from a Local First Auth app in the following format.
{
"code": "PERMISSION_NOT_DECLARED",
"message": "Permission not in manifest"
}
| Field | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | Unique error code |
message |
string | Yes | More details on the error code received |
JWT Structure
All data passed from the Local First Auth app to a mini app is done via signed JWTs (JSON Web Tokens).
JWT Header
It's useful to know what algorithm to use to decode the JWT. If you use a JWT library, this part is usually done behind the scenes for you.
{
"alg": "EdDSA",
"typ": "JWT"
}
| Field | Description |
|---|---|
alg |
Algorithm used to sign the JWT. |
typ |
Type of the JWT. Always "JWT". |
JWT Payload
Decoded data inside the JWT Payload.
{
"iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"aud": "https://yourdomain.com",
"iat": 1728393600,
"exp": 1728397200,
"type": "localFirstAuth:profile:disconnected",
"data":
{
"did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"name": "Danny Mathews",
"socials": [{ "platform": "INSTAGRAM", "handle": "dmathewwws" }]
}
}
| Claim | Description |
|---|---|
iss |
Issuer - Public key of the user's DID. Use this when verifying the JWT. |
aud |
Intended Audience - The mini app that requested the JWT. |
iat |
Issued at timestamp |
exp |
Expiration timestamp (default is 2 minutes) |
type |
Local First Auth function or event type |
data |
Type-specific payload |
Best Practices
- Decoding & verifying the JWT - Never trust unverified data. Decode JWTs using the
alg. Verify that the JWT has been signed by the user's public key (issfield). - Validate audience - Ensure the
audclaim matches the domain of the mini app. This is set by the Local First Auth app based on the url that launched the WebView. - Validate expiration - Reject expired tokens. Check the
expfield.
Making Authenticated Requests
When your mini app needs to make an authenticated request on behalf of a user, call getProfileDetails()to get a valid JWT for them. This can be used directly as a Bearer token to make authenticated requests ie) no need to build session tokens or additional auth infrastructure.
Your Mini App (Client-Side)
// Get profile JWT when you need to make an authenticated request
const jwt = await window.localFirstAuth.getProfileDetails();
// Use it as a Bearer token in your requests
const response = await fetch('https://yourdomain.com/api/posts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'},
body: JSON.stringify({ content: 'Hello world' })
}
);
if (response.ok) {
console.log('Post created successfully');
}
Your Backend
Your backend checks for a valid JWT before processing the rest of the request. The JWT contains the DID of the user making the request.
app.post('/api/posts', async (req, res) => {
try {
const authHeader = req.headers.authorization;
const { content } = req.body;
// Get User's JWT
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Invalid authorization header' });
}
const jwt = authHeader.slice(7).trim();
if (!jwt) { return res.status(401).json({ error: 'No token provided' }); }
// Decode and verify JWT signature using DID public key
const payload = await decodeAndVerifyJWT(jwt);
// Process authenticated request
await db.posts.create({
content,
authorId: payload.iss // User's DID from JWT
});
res.json({ success: true });
} catch (error) {
res.status(401).json({ error: 'Invalid JWT' });
}
});
Note: You will most likely need a new JWT for each request as JWTs expire after 2 minutes.
See code example for decodeAndVerifyJWT. We decode & verify JWT signature including making sure the aud claim is for our mini app.
License: Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
Author: Daniel Mathews ([email protected])
Last Modified: 2026-01-13