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
- Your browser navigates to your server's login-start route.
- The handler redirects to Enlitt, where the user signs in and authorizes your application.
- Enlitt redirects to your registered callback URL with a one-time code.
- Your callback handler validates the saved state and exchanges the code using your App key.
- The handler sets an
HttpOnly,Secure,SameSite=Laxcookie on your application's domain. - 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 forPOSTonly. - Responses. Every response is a
303redirect withCache-Control: no-store. - Where the user lands. Pass
?return_to=/some/pathto login-start or logout. It must be a path onappOrigin; anything else falls back todefaultReturnTo, so a login link cannot become an open redirect. - Failures. The callback refuses a missing, unknown, reused, or expired
statebefore it exchanges anything, and sets no cookie.
CallbackConfig
| Field | Required | Meaning |
|---|---|---|
clientId | Yes | The application's public client ID (ENLITT_CLIENT_ID). |
secretKey | No | The App key. Defaults to process.env.ENLITT_SECRET_KEY. |
redirectUri | Yes | Your callback URL. Must equal a registered callback URL exactly. |
appOrigin | Yes | Your application's origin, such as https://your-app.example. Bounds every redirect, and scopes the cookie. |
defaultReturnTo | Yes | Path on appOrigin to land on when no valid return_to was given. |
stateStore | Yes | Server-side login-state storage; see below. |
enlittOrigin | No | Where users sign in. Defaults to https://enlitt.com. Set https://staging.enlitt.com against staging. |
enlittApiOrigin | No | Origin for the server-to-server code exchange and revocation. Defaults to enlittOrigin. |
cookieName | No | Session cookie name. Defaults to enlitt_session. Use the same name in the session middleware. |
onSignIn | No | Hook 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.
Link your own users and history
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.