Enlitt

Login and sessions

Mount login routes, link local users, and run model calls in the personal user session.

Every standard or premium call needs a personal Enlitt session. Your model key authenticates your application; it does not sign in a user. If the user is signed out or their session is expired, the call fails with EnlittLoginRequiredError before a premium provider runs.

How login works

  1. Your browser navigates to your server's login-start route.
  2. The handler redirects to Enlitt, where the user signs in and authorizes your application.
  3. Enlitt redirects to your registered callback URL with a one-time code.
  4. Your callback handler validates the saved state and exchanges the code using your App key.
  5. The handler sets an HttpOnly, Secure, SameSite=Lax cookie on your application's domain.
  6. On later requests, session middleware reads that cookie and makes the session available to model calls.

Mount the three handlers

The handlers use standard Web Request and Response objects. Route the returned functions through your framework's server routing system:

import {
  createLoginStartHandler,
  createCallbackHandler,
  createLogoutHandler,
  type CallbackConfig,
  type StateStore
} from "@enlitt/sdk/server";

export function createAuthRoutes(stateStore: StateStore) {
  // The public client ID; `npx enlitt setup` writes it to .env.local.
  const clientId = process.env.ENLITT_CLIENT_ID;
  if (!clientId) throw new Error("Set ENLITT_CLIENT_ID on the server");

  const config: CallbackConfig = {
    clientId,
    appOrigin: "https://your-app.example",
    redirectUri: "https://your-app.example/auth/enlitt/callback",
    defaultReturnTo: "/",
    stateStore
    // secretKey defaults to the server's ENLITT_SECRET_KEY.
    // Against Enlitt staging only: enlittOrigin: "https://staging.enlitt.com"
  };

  return {
    login: createLoginStartHandler(config),
    callback: createCallbackHandler(config),
    logout: createLogoutHandler(config)
  };
}

Mount login at /auth/enlitt/login, callback at the exact registered /auth/enlitt/callback, and logout at /auth/enlitt/logout on your server. These paths are example choices for your application.

  • Methods. Login-start and the callback are browser navigations, so mount them for GET. Logout accepts any method: a link (GET) or a form (POST). To keep other sites from signing your users out with a link, mount logout for POST only.
  • Responses. Every response is a 303 redirect with Cache-Control: no-store.
  • Where the user lands. Pass ?return_to=/some/path to login-start or logout. It must be a path on appOrigin; anything else falls back to defaultReturnTo, so a login link cannot become an open redirect.
  • Failures. The callback refuses a missing, unknown, reused, or expired state before it exchanges anything, and sets no cookie.

CallbackConfig

FieldRequiredMeaning
clientIdYesThe application's public client ID (ENLITT_CLIENT_ID).
secretKeyNoThe App key. Defaults to process.env.ENLITT_SECRET_KEY.
redirectUriYesYour callback URL. Must equal a registered callback URL exactly.
appOriginYesYour application's origin, such as https://your-app.example. Bounds every redirect, and scopes the cookie.
defaultReturnToYesPath on appOrigin to land on when no valid return_to was given.
stateStoreYesServer-side login-state storage; see below.
enlittOriginNoWhere users sign in. Defaults to https://enlitt.com. Set https://staging.enlitt.com against staging.
enlittApiOriginNoOrigin for the server-to-server code exchange and revocation. Defaults to enlittOrigin.
cookieNameNoSession cookie name. Defaults to enlitt_session. Use the same name in the session middleware.
onSignInNoHook that links the signed-in user to your own users; see below.

The login handlers do not read ENLITT_BASE_URL; that variable steers only model calls. See Test and live applications.

The StateStore

Login-start creates a one-time state value and saves it; the callback must find and consume it. You supply where it is saved:

interface StateRecord {
  returnTo?: string;
  createdAt: number; // epoch milliseconds
  expiresAt: number; // epoch milliseconds
}

interface StateStore {
  set(state: string, record: StateRecord, ttlMs: number): void | Promise<void>;
  get(state: string): StateRecord | null | Promise<StateRecord | null>;
  // Read and delete in one step, so a state can be used only once.
  consume(state: string): StateRecord | null | Promise<StateRecord | null>;
}

Each method may return its value directly or as a promise. Return null for a state that is unknown or older than its ttlMs (milliseconds). consume must be atomic: two callbacks racing on one state must not both receive the record.

For one process in local development, a Map is enough:

import type { StateRecord, StateStore } from "@enlitt/sdk/server";

// Development only: every server process has its own Map, and a restart loses it.
export function memoryStateStore(): StateStore {
  const records = new Map<string, { record: StateRecord; deadline: number }>();
  const live = (state: string): StateRecord | null => {
    const entry = records.get(state);
    if (!entry) return null;
    if (entry.deadline <= Date.now()) {
      records.delete(state);
      return null;
    }
    return entry.record;
  };
  return {
    set(state, record, ttlMs) {
      records.set(state, { record, deadline: Date.now() + ttlMs });
    },
    get: live,
    consume(state) {
      const record = live(state);
      records.delete(state);
      return record;
    }
  };
}

In production, and in any deployment with more than one process or instance (serverless functions included), the login request and the callback can reach different instances, so use shared storage. With Redis (redis package, Redis 6.2 or later):

import type { StateRecord, StateStore } from "@enlitt/sdk/server";
import type { RedisClientType } from "redis";

export function redisStateStore(redis: RedisClientType): StateStore {
  const key = (state: string) => `enlitt:login-state:${state}`;
  const parse = (value: string | null): StateRecord | null =>
    value === null ? null : (JSON.parse(value) as StateRecord);
  return {
    async set(state, record, ttlMs) {
      await redis.set(key(state), JSON.stringify(record), { PX: ttlMs });
    },
    async get(state) {
      return parse(await redis.get(key(state)));
    },
    async consume(state) {
      return parse(await redis.getDel(key(state))); // atomic read and delete
    }
  };
}

A database table with an expiry column works the same way, provided consume deletes and returns the row in one statement.

Next.js App Router

Each handler is a Route Handler. Build the handlers once in a server-only module, then export them from the route files:

// lib/enlitt/auth.ts
import "server-only";
import { createAuthRoutes } from "./create-auth-routes"; // the function above
import { redisStateStore } from "./state-store";
import { redis } from "../redis";

export const enlittAuth = createAuthRoutes(redisStateStore(redis));
// app/auth/enlitt/login/route.ts
import { enlittAuth } from "@/lib/enlitt/auth";
export const GET = enlittAuth.login;
// app/auth/enlitt/callback/route.ts
import { enlittAuth } from "@/lib/enlitt/auth";
export const GET = enlittAuth.callback;
// app/auth/enlitt/logout/route.ts
import { enlittAuth } from "@/lib/enlitt/auth";
export const POST = enlittAuth.logout;

Sign in with a plain link, <a href="/auth/enlitt/login?return_to=/chat">, and sign out with <form method="post" action="/auth/enlitt/logout">. Do not use a client-side fetch for either: they are full-page redirects.

Wrap each Route Handler that makes model calls, as in Wrap the model request. Next.js middleware.ts (or proxy.ts) cannot do this: the session context must surround the code that calls the model, in the same request.

The successful server-side code exchange returns an opaque session_token and a verified subject. The subject identifies this personal user within this application. It stays the same across devices, new login sessions, key rotation, and revocation followed by reauthorization. Other applications receive a different subject for the same person. Treat it as an opaque string, not an email, internal Enlitt user ID, or credential.

Add the optional onSignIn hook to your CallbackConfig to find or create a user in your own database and establish your own application session:

import type { CallbackConfig } from "@enlitt/sdk/server";

type LocalUserStore = {
  // Enforce a unique constraint on the Enlitt subject for concurrent logins.
  findOrCreateByEnlittSubject(subject: string): Promise<{ id: string }>;
};
type LocalSessions = {
  // Return your application's signed/opaque HttpOnly, Secure, SameSite=Lax cookie.
  createCookie(userId: string): Promise<string>;
};

export function withLocalUsers(
  config: CallbackConfig,
  users: LocalUserStore,
  sessions: LocalSessions
): CallbackConfig {
  return {
    ...config,
    async onSignIn({ subject }, response) {
      const user = await users.findOrCreateByEnlittSubject(subject);
      const cookie = await sessions.createCookie(user.id);
      response.headers.append("set-cookie", cookie);
    }
  };
}

Pass the resulting config to createCallbackHandler. The SDK awaits the hook before appending its own Enlitt cookie; both cookies reach the browser on the same redirect. The hook runs once per consumed login state and receives only the verified subject and the redirect response. A subject in the callback URL is ignored. Failed login or malformed exchange data never invokes the hook. If your database/session setup fails, its error propagates to your server and the SDK returns no successful login response. Handle that error in your server without logging credentials.

Store your app's conversations against its local user ID (or the unique Enlitt subject), and authorize history requests through your own authenticated session. An unsigned cookie containing the subject is not proof of identity. The SDK neither stores the subject nor adds it to getEnlittSession(); that function still returns only an opaque model-call credential. Your local session lifecycle is yours: clear its cookie in your logout route as well as calling the SDK's logout handler. Enlitt revocation immediately prevents further model calls, but it does not automatically revoke your independent application session.

Existing integrations may omit the hook. Older SDKs ignore the additive subject response field. Deploy the updated Enlitt backend before using this SDK's updated callback handler, which requires a valid subject in the exchange.

Wrap the model request

Run the model operation inside the middleware's next callback. Creating a middleware function without invoking it around the operation does not install session context.

import { enlittSessionMiddleware } from "@enlitt/sdk/core";
import { enlitt, ENLITT_STANDARD_MODEL } from "@enlitt/sdk/vercel";
import { generateText } from "ai";

const withSession = enlittSessionMiddleware();

export async function POST(request: Request): Promise<Response> {
  return withSession(request, async () => {
    const result = await generateText({
      model: enlitt(ENLITT_STANDARD_MODEL),
      prompt: "Write a short welcome message."
    });
    return Response.json({ text: result.text });
  });
}

Add error handling to send signed-out users through login. The middleware itself allows requests with no cookie to continue; it does not choose which routes your application protects.

To check whether the visitor is signed in, call getEnlittSession(), also from @enlitt/sdk/core, inside the same wrapper:

import { enlittSessionMiddleware, getEnlittSession } from "@enlitt/sdk/core";

const withSession = enlittSessionMiddleware();

// app/api/me/route.ts: lets your UI decide between "Sign in" and "Sign out".
export async function GET(request: Request): Promise<Response> {
  return withSession(request, async () => Response.json({ signedIn: getEnlittSession() !== null }));
}

It returns null for a signed-out request inside the wrapper, and throws EnlittConfigurationError when called outside one. A non-null session may still be expired or revoked; Enlitt checks validity when you make a call, which then fails with EnlittLoginRequiredError.

If you customize cookieName, give the same name to the login handlers and session middleware. If your framework uses a different middleware shape, runWithEnlittSession(session, callback) provides the same request context.

Background jobs and linked accounts

For a server job outside an HTTP request, construct a session from an actual Enlitt token your server securely stored, then pass it explicitly:

import { enlittSession } from "@enlitt/sdk/core";
import { enlitt, ENLITT_STANDARD_MODEL } from "@enlitt/sdk/vercel";

export function modelForLinkedAccount(storedToken: string) {
  const session = enlittSession(storedToken);
  return enlitt(ENLITT_STANDARD_MODEL, { session });
}

A session token is opaque. Do not derive one from an email, user ID, or client input. Never expose it or your business key in logs or browser JavaScript. Keep all SDK model, callback, and reporting imports in server-only modules. The browser carries the HttpOnly cookie automatically.

On this page