Authentication

Lipafy accepts five credential types. They all converge on the same req.auth shape — routes don’t branch on credential type, only on whether requireAuth(req) or requireAdmin(req) succeeds.

The five credential types

TypeHow it’s presentedWho uses itTTL
API keyAuthorization: Bearer lip_live_…Servers, CI, the lipafy curl shimuntil revoked
OAuth access token (JWT)Authorization: Bearer <jwt>Connected agents (Claude Desktop, Cline, Cursor, custom)15 min, rotates via refresh
OAuth refresh tokenPOST /oauth/token with grant_type=refresh_tokenConnected agents, automatically90 days, rotating
Dashboard session cookieCookie: lipafy_session=…Browsers signed into the next-app dashboardconfigurable (default 30 days)
Wallet signatureinline on the protected routePay.sh-style flows for approvals + intent mandatesper-message, single-use

The first three are HTTP Bearer; the fourth is a cookie automatically forwarded by the browser; the fifth is request-body data validated server-side.

API keys

$POST /v1/accounts/me/api-keys
${ "name": "ci", "expires_at": "2026-12-31T00:00:00Z" }

Returned exactly once in plaintext. Stored only as SHA-256(salt || plaintext). Pattern: lip_live_<64 hex>. Each API key gets a paired grants row so it has its own spending controls.

Revoke via DELETE /v1/accounts/me/api-keys/:id. Subsequent requests with that key return api_key_revoked.

OAuth 2.1 (for agents)

Lipafy is a standards-compliant OAuth 2.1 authorization server. The full surface:

EndpointPurpose
GET /.well-known/oauth-authorization-serverDiscovery metadata
POST /oauth/registerDynamic Client Registration (RFC 7591) — agents create their own client_id
GET /oauth/authorizeUser-facing consent screen
POST /oauth/tokenCode exchange + refresh
POST /oauth/revokeRevoke a grant programmatically

PKCE S256 is required for all flows; plaintext code_verifier is rejected. Refresh tokens rotate on every use; reuse of an old refresh token revokes the whole grant (standard detect-and-revoke).

The auth plugin verifies every access token against oauth_grants.revoked_at on every request, so revocation is instant — not “next refresh.”

See the OAuth API reference →

Sign-In with Ethereum (SIWE) — wallet login

$POST /v1/auth/wallet/nonce
${ "address": "0x..." }
$ { "nonce": "...", "message": "app.lipafy.xyz wants you to sign in..." }
$
$# User signs `message` with their wallet (no gas, no tx).
$POST /v1/auth/wallet/verify
${ "message": "...", "signature": "0x..." }
$ Session cookie + JWT, plus the linked wallet is stored.

The recovered EVM address is uniquely tied to a Lipafy account in wallet_addresses. The same wallet can sign approvals and intent mandates later — see the relevant concept pages.

Same email across providers — what happens

If you signed up with Google using you@example.com, then later sign in with GitHub whose primary verified email is also you@example.com, Lipafy automatically links them into one account. You keep the same balance, capabilities, and history.

The matching rule, in order:

  1. Exact identity match wins. If (provider, sub) is already linked, that account is returned, period.
  2. Verified email match. If no identity is linked yet but the provider returns email_verified=true and an account already exists with that email, Lipafy links the new identity to it. Both Google and GitHub mark their primary emails as verified; this path catches the common “same person, different provider” case.
  3. Otherwise create a new account. If neither match applies, a fresh account is created.
You signed up with…You later sign in with…Result
Google you@example.comGitHub with same verified primarySame account. Auto-linked.
Google you@example.comWallet 0xabc…New account (wallet has no email to match). To merge, stay signed in via Google and sign in with the wallet — that auto-links it.
Wallet 0xabc…Google you@example.comNew account (wallet account has a synthetic …@wallet.lipafy.local email). Same auto-link path works in reverse.
GitHub private email (*@users.noreply.github.com)Google you@example.comTwo separate accounts. GitHub’s noreply doesn’t match anything real. Use manual linking from /account.

If you ever end up with two separate accounts and want to merge balance + history, that’s a manual support ticket today — Lipafy doesn’t expose self-serve account merging because doing it correctly with active wallets is hard.

Linking multiple identities to one account

Lipafy supports attaching more than one sign-in method to the same account. A user who first signed up with Google can later add a wallet (and vice versa) — they stay on one Lipafy account with one balance, one history, and one identity for approvals.

Auto-link: the regular sign-in endpoints are session-aware. If you call GET /v1/auth/google/start or POST /v1/auth/wallet/verify while already signed in (session cookie or Bearer token present), Lipafy treats the request as a link rather than a fresh sign-in. The result is:

  • New identity → attached to your current account.
  • Already attached to your current account → idempotent success.
  • Already attached to a different account → 409 conflict.

So the user-facing pattern is just: “sign in with the other method while signed in.” The dashboard exposes this on /account as Link Google / Link wallet buttons that hit the same routes.

Explicit endpoints (programmatic clients):

EndpointPurpose
GET /v1/auth/identitiesList linked sign-in methods on the current account
POST /v1/auth/wallet/linkAttach a wallet to the current account (SIWE signature)
DELETE /v1/auth/wallet/:addressUnlink a wallet — refuses if it’s the last identity
GET /v1/auth/google/link/startBegin a Google flow that links to the current account
DELETE /v1/auth/google/:subjectUnlink a Google identity — same last-identity guard

The last-identity guard prevents users from locking themselves out: you can’t remove your final sign-in method. Link another first.

Sessions (browsers)

After Google or wallet sign-in, Lipafy sets a lipafy_session cookie. The cookie holds an opaque token; only the SHA-256 hash is stored in sessions. Logging out flips sessions.revoked_at and the cookie stops working on the next request, not at TTL.

Cookie attributes: HttpOnly; Secure (in prod); SameSite=Lax; Path=/. Configurable domain via SESSION_COOKIE_DOMAIN.

Wallet signatures (on specific protected actions)

For specific high-stakes actions Lipafy supports purely signature-based authorization, where the request body carries a signature over a server-canonical message:

  • Approvals: POST /v1/approvals/:id/decide-by-signature
  • Intent mandates: POST /v1/intent-mandates (create flow)

These don’t require a Bearer header or session cookie. The signature itself authorises the action, validated against the wallet linkage on the owning account.

Choosing a credential type

You’re…Use…
Building a backend serviceAPI key
Writing CI scriptsAPI key (via LIPAFY_TOKEN)
Writing an agent users will installOAuth 2.1 + DCR (so each user grants its own scope)
Wrapping curl from a scriptlipafy curl (uses your stored API key)
Building a browser dashboardSession cookie via Google or wallet sign-in
Approving without dashboardWallet signature