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

Client types

When you register an app, you choose how it will authenticate. Pick based on where your code runs.

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).

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/callback

The 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:

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.refreshToken

Expo (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.code

Native 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 } }
MethodCallsReturns
Email code/native/email/native/email/verify{ auth_code }
SMS code/native/sms/native/sms/verify{ auth_code }
Applenative ASAuthorization/native/social/verify{ auth_code }
Googlenative Credential Manager → /native/social/verify{ auth_code }
Passkeyexisting /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):

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=S256

The 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 /authorize

2 · 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 route

Phone (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_access

You then get phone_number (E.164, e.g. +905321234567) and phone_number_verified: true in the id_token / /userinfo.

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

Refresh & logout

Access tokens last 15 minutes; refresh tokens last 30 days.

Endpoint reference