Auto-login
Turn a DavidApps visitor into a session in the app's own auth system, without changing that system.
Auto-login is for an app that already has sign-in code you do not want to replace. The app keeps its user table, its session store, and its framework. DavidApps identifies the person in front of the app and hands over a signed one-use handoff; the app checks the signature and then calls its own "sign this user in" path, exactly as it would after any other successful login.
It takes three files and one row shape in your database.
Before you start
The app must be behind DavidApps protection, because the signed handoff is a request header that only DavidApps may set. See Protect an app.
You also need the public half of the signing key, its key ID, and the app's ID and origin. The signing keys are pinned in configuration and read once at startup; nothing here fetches keys over the network, and no value inside the handoff can select which key verifies it.
DAVIDAPPS_ASSERTION_KID=... # key ID, matched exactly
DAVIDAPPS_ASSERTION_PUBLIC_KEY=... # 32 bytes, base64url
DAVIDAPPS_PROJECT_ID=... # the app's ID in the dashboard
DAVIDAPPS_PROJECT_ORIGIN=https://app.example.com
AUTH_SECRET=... # your own framework's secret// src/lib/project.ts
import { Buffer } from "node:buffer";
import {
normalizeProject,
type DavidAppsProject,
} from "@davidapps/sdk-core/configuration";
import * as z from "zod";
const environmentSchema = z.strictObject({
AUTH_SECRET: z.string().min(32).max(512),
DAVIDAPPS_ASSERTION_KID: z.string().regex(/^[A-Za-z0-9._~-]{1,128}$/u),
DAVIDAPPS_ASSERTION_PUBLIC_KEY: z.string().regex(/^[A-Za-z0-9_-]{43}$/u),
DAVIDAPPS_PROJECT_ID: z.string().min(1).max(256),
DAVIDAPPS_PROJECT_ORIGIN: z.string(),
});
const environment = environmentSchema.parse({
AUTH_SECRET: process.env.AUTH_SECRET,
DAVIDAPPS_ASSERTION_KID: process.env.DAVIDAPPS_ASSERTION_KID,
DAVIDAPPS_ASSERTION_PUBLIC_KEY: process.env.DAVIDAPPS_ASSERTION_PUBLIC_KEY,
DAVIDAPPS_PROJECT_ID: process.env.DAVIDAPPS_PROJECT_ID,
DAVIDAPPS_PROJECT_ORIGIN: process.env.DAVIDAPPS_PROJECT_ORIGIN,
});
const normalized = normalizeProject({
applicationId: environment.DAVIDAPPS_PROJECT_ID,
origin: environment.DAVIDAPPS_PROJECT_ORIGIN,
});
if (!normalized.ok) throw new Error("Invalid DavidApps project configuration");
const publicKey = Buffer.from(
environment.DAVIDAPPS_ASSERTION_PUBLIC_KEY,
"base64url",
);
if (publicKey.byteLength !== 32) {
throw new Error("DAVIDAPPS_ASSERTION_PUBLIC_KEY must be 32 bytes");
}
export const authSecret = environment.AUTH_SECRET;
export const assertionKeys = Object.freeze({
[environment.DAVIDAPPS_ASSERTION_KID]: Uint8Array.from(publicKey),
});
export const project: DavidAppsProject = normalized.value;The one model
One row per person per issuer, keyed by the issuer and the stable app-specific ID. That pair is the whole identity key. Nothing here matches on email address, because an email address is not proof of anything and can change.
model ExternalIdentity {
id String @id @default(cuid())
issuer String
subject String
userId String
user User @relation(fields: [userId], references: [id])
@@unique([issuer, subject])
}The unique constraint is what makes first sign-in race-safe. Two simultaneous first requests both try to create the row; one wins, the loser sees the conflict and reads back the winner. That behaviour is built in, provided you tell the SDK how to recognise your database's conflict error.
File 1 — send the visitor to the auto-login address
The signed handoff arrives as a request header on the page the visitor asked for. It must not end up in an address, in browser history, or in client-side JavaScript, so this file only redirects; the header is read later, on the request to the auto-login address, where a fresh handoff arrives with it.
// src/proxy.ts
import { createDavidAppsHydrationProxy } from "@davidapps/sdk-next/hydration-proxy";
import { AUTHJS_SESSION_COOKIE } from "./lib/hydration";
import { project } from "./lib/project";
import { hasSessionToken } from "./lib/store";
export const proxy = createDavidAppsHydrationProxy({
hasSession: (request) =>
hasSessionToken(request.cookies.get(AUTHJS_SESSION_COOKIE)?.value),
project,
});
export const config = {
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"],
};It redirects only for a GET top-level document navigation, on the origin you
configured, when there is no app session yet and no recent-sign-in hint cookie,
and when the request carries exactly one handoff header. Everything else — API
calls, sub-resources, the auto-login address itself, a visitor who is already
signed in — is passed straight through. If your session check throws, it is
treated as "no session" rather than as an error page.
File 2 — verify the handoff and create your session
// src/lib/hydration.ts
import {
createHydrationService,
type HydrationService,
type NativeSessionCookie,
} from "@davidapps/sdk-core/assertion";
import { createRaceSafeExternalIdentityResolver } from "@davidapps/sdk-core/external-identity";
import { assertionKeys, project } from "./project";
import { assertionReplays } from "./replay-store";
import { AUTHJS_SESSION_MAX_AGE_SECONDS } from "./session-policy";
import {
createRotatedSession,
findIdentity,
provisionIdentity,
UniqueIdentityConflict,
type LocalUser,
} from "./store";
export const AUTHJS_SESSION_COOKIE = "authjs.session-token";
const identities = createRaceSafeExternalIdentityResolver<LocalUser>({
find: ({ issuer, subject }) => Promise.resolve(findIdentity(issuer, subject)),
isUniqueConflict: (cause) => cause instanceof UniqueIdentityConflict,
provision: (claims) =>
Promise.resolve(
provisionIdentity({
appRole: claims.o.rol,
email: claims.email,
emailVerified: claims.email_verified,
issuer: claims.iss,
name: claims.name,
subject: claims.sub,
}),
),
});
const configured = createHydrationService({
clock: { nowSeconds: () => Math.floor(Date.now() / 1_000) },
identities,
keys: assertionKeys,
project,
replays: assertionReplays,
sessions: {
create: ({ user }) => {
const session = createRotatedSession(user);
const cookie: NativeSessionCookie = {
httpOnly: true,
maxAgeSeconds: AUTHJS_SESSION_MAX_AGE_SECONDS,
name: AUTHJS_SESSION_COOKIE,
path: "/",
sameSite: "Lax",
secure: !project.isLocalDevelopment,
value: session.sessionToken,
};
return Promise.resolve({ cookies: [cookie], rotated: true });
},
},
});
if (configured === undefined) {
throw new Error("Invalid DavidApps signing-key configuration");
}
export const hydration: HydrationService = configured;createHydrationService returns undefined — rather than throwing later — if
the pinned key set is empty, larger than eight keys, or contains a key that is
not exactly 32 bytes. Check it at startup, as above.
File 3 — the route handler
// src/app/%5Fda/[...da]/route.ts
import { createDavidAppsRouteHandlers } from "@davidapps/sdk-next/routes";
import { hydration } from "@/lib/hydration";
import { project } from "@/lib/project";
const handlers = createDavidAppsRouteHandlers({
completeSilentCallback: () =>
Promise.resolve({ error: "callback_denied" as const, kind: "error" }),
hydration,
project,
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export const GET = handlers.GET;
export const POST = handlers.POST;The directory is spelled %5Fda on disk because Next treats a leading
underscore as private; the address the browser uses is /_da. Deny the silent
sign-in callback, as above, unless the app is also using
Sign in with DavidApps.
The route must run on the Node runtime — signature verification needs it — and it must not be cached.
What gets checked
Nothing is trusted until all of this passes, and every failure produces the same refusal:
- The signature, against a pinned key chosen by exact key ID. An unknown key ID stops there; a handoff cannot nominate its own key.
- The intended app, both the exact hostname and the app's own ID. A handoff minted for one of your hosts is not valid at another.
- The lifetime. A handoff lives 60 seconds, with 60 seconds of clock tolerance either side, and its issue and expiry times must agree with each other exactly.
- One use, ever. The identifier is consumed atomically before any session work happens. A replay of a still-valid handoff is refused, and if the store cannot answer, the request is refused too.
- The destination, which must be a canonical same-origin relative address. Absolute addresses, network paths, backslashes, fragments, control characters, and encoded separators are rejected rather than rewritten.
- The identity, resolved on issuer and stable app-specific ID only.
- The session, which must report that it rotated its identifier, and whose
cookies must be
HttpOnly, host-only in naming,Secureoutside loopback development, and no longer than 900 seconds.
Rotating on sign-in is what defeats session fixation: an identifier planted in the browser beforehand is discarded rather than promoted.
Visitor handoffs are refused unless the app has visitor sign-in switched on.
On success the browser gets a 303 to the address it originally asked for,
carrying your session cookies plus a short-lived hint cookie so the next
navigation does not run the check again. On failure it gets a 403 with a
small JSON body and no explanation.
Choosing how the session is made
The one thing the SDK never does is decide what a session is. You hand it a function; it validates whatever that function returns. There are three shapes that function usually takes:
- Call your framework's own sign-in path and return the cookies it sets. Closest to what the rest of your app already does.
- Write a session record through your own database adapter and return the
cookie that points at it. This is what
examples/consumer-authjsdoes, and it is the option to pick when the framework's sign-in call assumes a provider redirect that has not happened here. - Return a sealed cookie you signed yourself, for an app whose sessions are stateless.
Pick one. Nothing switches between them at runtime, and there is no fallback if your choice fails — a failure is a refusal, which is the behaviour you want on a sign-in path.
What production needs that the examples do not have
The examples in this repository are proofs, not deployment templates. Four pieces of state are on the critical path, and all four need real storage:
| State | What it must guarantee |
|---|---|
| One-use handoff store | Atomic consume, TTL past the handoff lifetime and clock tolerance, bounded, fail closed |
| External identity records | The unique pair, and a conflict error your resolver can recognise |
| Sessions | Whatever your framework needs, shared across every instance |
| Sign-in state | Only if you also use silent sign-in; same atomicity rules |
examples/consumer-authjs backs its one-use store with files on disk and keeps
its identities and sessions in the running process. That is correct for one
host with one volume and is deliberately not a horizontally scalable database.
Running two instances against that setup would let a handoff be spent twice.
Replace all four with storage that preserves the same guarantees before this
goes anywhere real.
Next
- Protect an app — the protection this depends on.
- Groups and access — who reaches the app in the first place.