Integration guide
LycianAuth is a standard OpenID Provider. Apps integrate with the Authorization Code flow and PKCE, then verify EdDSA-signed JWTs locally. Any OIDC client library works; the SDKs below are optional.
Issuer · https://auth.lycian.app
Core concepts
- App — each product is one OAuth client, identified by a
client_id(for exampleo-pt-pt). - User — identity is the pair
(app, email). The same email in two apps is two separate users; they never share access. - No public sign-up — you add users from the dashboard. Only provisioned users can receive a login code.
- Login — the user enters their email on the hosted page and types a six-digit code. The JWT
aud(audience) is always yourclient_id.
Client types
When you register an app, you choose how it will authenticate. Pick based on where your code runs.
- Public client — for code that runs on the user's device: single-page web apps and mobile apps. There is no client secret, because anything shipped to a browser or phone can be read by the user, so a secret couldn't stay secret. Security comes from PKCE (a one-time proof the app generates per login) plus your registered redirect URIs.
- Confidential client — for code that runs on a server you control: a backend or server-rendered web app. It gets a client secret — a private password shown to you once at creation. Your backend stores it and sends it when exchanging the login code for tokens, proving the request really comes from your server.
If you're not sure: a React/Vue/mobile app is public; a Node/Python/Rails backend is confidential.
Desktop, CLI & native apps
Apps that run on the user's own machine (Electron, native desktop, a local CLI) can't use a hosted HTTPS
redirect — there's no public URL to come back to. Use the loopback redirect pattern
(RFC 8252): register a public
client and a 127.0.0.1 redirect URI. The app runs a tiny local HTTP listener that catches the
?code, then opens the login in the user's real system browser (never an embedded webview).
- Register the redirect without a port:
http://127.0.0.1/callback. A public client is treated as a native client, so any loopback port matches at runtime — your app can bind an ephemeral port (http://127.0.0.1:54321/callback) and it still validates. Only the path must match. http://on a loopback address is allowed without enabling Dev mode. Dev mode is only forhttp://on non-loopback hosts during local development.- Open the authorize URL with the OS browser opener (
shell.openExternal,xdg-open,open), not a webview — webviews break the user's session and password manager and are rejected by some IdPs.
Same app shipped both ways? Redirect URIs is a list — add both the loopback and the hosted HTTPS callback to one client, e.g.:
http://127.0.0.1/api/auth/callback
https://app.example.com/api/auth/callbackThe app sends whichever applies at runtime. If the hosted build is server-side (has somewhere safe to keep a secret), prefer a separate confidential client for it instead.
Mobile apps (iOS, Android, React Native)
A mobile app is a public client (no secret) using Authorization Code + PKCE. Any standard OIDC/AppAuth library works — point it at the discovery URL and it does the flow for you. Two rules:
- Use a custom-scheme redirect — register a reverse-DNS scheme on the client, e.g.
com.yourcompany.app://oauth/callback. (Ahttp://127.0.0.1loopback redirect also works; see above.) The scheme must match what your app registers with the OS. - Open the system browser, never an embedded webview. Use
ASWebAuthenticationSession(iOS) / Custom Tabs (Android); the libraries below do this. Webviews break passwordless sign-in and are rejected by Google/Apple.
Discovery: https://auth.lycian.app/.well-known/openid-configuration · scopes
openid email profile offline_access · no client secret.
React Native (react-native-app-auth):
import { authorize } from "react-native-app-auth";
const config = {
issuer: "https://auth.lycian.app",
clientId: "your-client-id",
redirectUrl: "com.yourcompany.app://oauth/callback",
scopes: ["openid", "email", "profile", "offline_access"],
// PKCE is on by default; no clientSecret for a public client.
};
const result = await authorize(config);
// result.accessToken (JWT), result.idToken, result.refreshTokenExpo (expo-auth-session):
import * as AuthSession from "expo-auth-session";
const discovery = AuthSession.useAutoDiscovery("https://auth.lycian.app");
const redirectUri = AuthSession.makeRedirectUri({ scheme: "com.yourcompany.app" });
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{ clientId: "your-client-id", redirectUri,
scopes: ["openid", "email", "profile", "offline_access"], usePKCE: true },
discovery,
);
// call promptAsync() on your sign-in button; exchange response.params.codeNative iOS/Android use AppAuth-iOS /
AppAuth-Android the same way. Verify the access token
on your API exactly as in step 3 (JWKS at https://auth.lycian.app/keys).
In-app sign-in — no browser (first-party apps)
For apps you own, you can skip the hosted page entirely and build your own native
UI, while still getting standard LycianAuth tokens. The native API drives the same authorization-code
machinery: each method returns an auth_code you exchange at /oauth/token with PKCE
exactly as in steps 1–2. Token verification, refresh and logout are unchanged.
Open a session, then run any enabled method:
POST https://auth.lycian.app/native/start
{ "client_id":"o-pt-pt", "scope":"openid email phone offline_access",
"code_challenge":"<S256>", "code_challenge_method":"S256", "nonce":"<random>" }
-> { "session_id":"…", "methods": { "otp":true,"sms":true,"passkey":true,
"google":true,"apple":true,"register":true } }| Method | Calls | Returns |
|---|---|---|
| Email code | /native/email → /native/email/verify | { auth_code } |
| SMS code | /native/sms → /native/sms/verify | { auth_code } |
| Apple | native ASAuthorization → /native/social/verify | { auth_code } |
native Credential Manager → /native/social/verify | { auth_code } | |
| Passkey | existing /login/passkey/login/* → /native/code | { auth_code } |
Then exchange the code (no redirect_uri needed for native):
POST https://auth.lycian.app/oauth/token
grant_type=authorization_code&code=<auth_code>&client_id=o-pt-pt&code_verifier=<verifier>Platform rules (important):
- Social uses the native provider SDKs, never a webview. Apple →
ASAuthorizationAppleIDProvider; Google → Credential Manager / Google Identity Services. Send the resulting id_token plus the nonce you bound into the request. The operator must register your native audiences (your iOS/Android OAuth client ids for Google; your app bundle ids for Apple) — theaudis checked against that allowlist. - SMS is attestation-gated. When the server requires it,
/native/smsneeds a valid Android Play Integrity token (Apple App Attest is not yet supported) inattestation, or it's denied — toll-fraud protection. - Passkeys: set up the iOS Associated Domain / Android Digital Asset Links for the issuer host.
- Store the
refresh_tokenin the platform keystore (iOS Keychain / Android Keystore); it rotates on use.
Full request/response schemas: https://auth.lycian.app/openapi.yaml.
1 · Send the user to log in
Redirect the browser to the authorization endpoint with a PKCE challenge:
GET https://auth.lycian.app/authorize
?client_id=o-pt-pt
&redirect_uri=https://o-pt-pt.com/callback
&response_type=code
&scope=openid email profile offline_access
&state=RANDOM_STRING
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256The user lands on the hosted login page, signs in, and is redirected back to
redirect_uri?code=...&state=.... Verify that state matches what you sent.
Generate the PKCE pair before the redirect and keep the verifier until step 2
(code_challenge_method is always S256):
// browser / Node
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
// stash { verifier, state }; send challenge + state on /authorize2 · Exchange the code for tokens
From your app, exchange the authorization code:
POST https://auth.lycian.app/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=THE_CODE
&redirect_uri=https://o-pt-pt.com/callback
&client_id=o-pt-pt
&code_verifier=THE_VERIFIER # public clients (PKCE)
# confidential clients also send their client secret
# (HTTP Basic auth, or client_secret_post)You receive an access_token (a JWT), an id_token, a refresh_token,
and expires_in.
3 · Verify access tokens — your API
On your API, verify the JWT on each request with any standard OIDC/JOSE library — there is nothing
LycianAuth-specific to verify. Fetch the JWKS once (cache it; keys rotate), then check the signature,
iss, aud and expiry. Example with jose on Node/TypeScript:
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://auth.lycian.app/keys"));
async function verify(accessToken) {
const { payload } = await jwtVerify(accessToken, JWKS, {
issuer: "https://auth.lycian.app",
audience: "o-pt-pt", // your client_id
});
const userId = payload.sub;
const meta = payload["urn:lycian:metadata"] ?? {}; // your app's metadata (see below)
return { userId, meta };
}No call back to LycianAuth is needed per request. The same applies in any language: load the JWKS from
https://auth.lycian.app/keys and verify the EdDSA signature, issuer, audience and expiry with your
platform's JWT library.
4 · Browser — TypeScript
For single-page apps, the browser SDK handles the PKCE dance:
import { LycianAuth } from "./lycianauth";
const client = new LycianAuth({
issuer: "https://auth.lycian.app",
clientId: "o-pt-pt",
redirectUri: location.origin + "/callback",
});
await client.login(); // on your sign-in button
const tokens = await client.handleCallback(); // on your /callback routePhone (SMS code) login
Your app needs no extra code — it's the same authorize → code → token flow. When an app has SMS code (phone) enabled (dashboard → Auth methods), the hosted login page offers “Sign in with phone instead”: the user enters a phone number, gets a one-time SMS code, and LycianAuth issues its own tokens exactly as for email.
To receive the number in tokens, request the phone scope (the operator must also add
phone to the app's allowed scopes):
&scope=openid phone offline_accessYou then get phone_number (E.164, e.g. +905321234567) and
phone_number_verified: true in the id_token / /userinfo.
- A user may have a phone but no email (phone-only signup) or vice versa. Request both
emailandphoneif your app accepts either, and always key the user offsub(stable id) — never the email or phone. - Registration follows the same Self-service register toggle as email/social: off → only dashboard-added users; on → first verified phone sign-in creates the user.
- Numbers are normalized to E.164; a bare Turkish number like
0532…resolves to+90532….
Metadata & custom claims
Every user has a metadata bag — an arbitrary JSON object you set per user in the dashboard. It is the way you attach app-specific data to an identity: a subscription plan, an expiry date, feature flags, seat counts, a tenant id, internal roles — whatever your app needs.
LycianAuth treats the bag as opaque. It never reads, validates, or understands your keys
(much like Stripe's metadata or Auth0's app_metadata). Your app owns the
schema: the meaning of the keys lives in your code, not here. There is no central registry, and two
apps never collide — the bag is stored per (app, user), so each app only ever sees its own users'
data.
Setting it
Dashboard → your app → open a user → expand Metadata → edit the JSON → save. It must be a JSON object. Example for a licensing app:
{ "plan": "pro", "expires_at": "2027-05-01", "features": ["export", "api"] }Other apps shape it however they like, e.g. { "seats": 5, "tenant": "acme", "role": "admin" }.
Reading it
The bag is surfaced as the namespaced claim urn:lycian:metadata in the access token,
the id_token, and at /userinfo. Read it straight off the verified access token —
no extra round-trip:
// Read it off the verified access token payload (any language)
const meta = payload["urn:lycian:metadata"] ?? {};
if (meta.plan === "pro") { /* unlock */ }
if (meta.expires_at && new Date(meta.expires_at) < new Date()) { /* expired */ }
// Or fetch it with the access token
GET https://auth.lycian.app/userinfo → { "sub": "...", "email": "...", "urn:lycian:metadata": { ... } }Behaviour & gotchas
- Empty bag → no claim. The claim is omitted entirely when a user's metadata is empty, so
guard for its absence (default to
{}). - Disabled users get nothing. A disabled user produces no claims — disabling a user (or clearing their bag) is your revocation lever; it bites on the next token refresh.
- Tokens are snapshots. A changed bag reaches the client on the next token issue (login or refresh), not instantly. Size the access-token lifetime (15 min) and your refresh cadence accordingly.
- Don't put secrets in it. It travels inside the JWT, which is base64 — readable by anyone holding the token. It's for entitlements, not credentials.
Refresh & logout
- Refresh — call
POST /oauth/tokenwithgrant_type=refresh_token. Refresh tokens rotate: each use returns a new one and invalidates the previous. - Logout — redirect the user to
https://auth.lycian.app/end_session. - Profile data — email and name live in the
id_tokenand at/userinfo, not in the access token. Call/userinfowith the access token when you need them.
Access tokens last 15 minutes; refresh tokens last 30 days.
Endpoint reference
/.well-known/openid-configuration— discovery document./authorize— start the login flow./oauth/token— exchange code, or refresh./userinfo— claims for the bearer access token./keys— JWKS (public keys for verification)./end_session— logout./native/*— in-app (browser-free) auth for first-party apps (see above).- /openapi.yaml — machine-readable spec.