Login with providers, passkeys, or a phone code
Services only, like password login: @warlock.js/auth ships no routes. Every
method below ends in authService.completeLogin(user, deviceInfo?), which
returns the same LoginResult as authService.login — apply auth.canAuthenticate
(403 on refusal), issue tokens, and hand back to the app the same way:
const { user, tokens } = await completeProviderLogin(User, "google", request, response);authService.setAuthCookie(response, tokens.accessToken); // or return tokens as JSONInstall
Section titled “Install”| Method | Command | Installs |
|---|---|---|
warlock add auth-google | jose | |
| Apple, LinkedIn | warlock add auth-google (or npm install jose). There is no separate auth-apple or auth-linkedin feature yet. | jose |
| GitHub, Discord, Facebook, X | nothing. These use plain OAuth 2 over fetch. | nothing |
| Passkeys | warlock add auth-passkeys | @simplewebauthn/server (add @simplewebauthn/browser to your client bundle) |
| Phone code | warlock add notifications + your own sms/whatsapp channel | nothing else — auth ships no SMS/WhatsApp driver |
Each SDK is an optional peer, loaded with import() only when that method
runs. A missing SDK throws AuthProviderSdkMissingError (500) naming the
warlock add command to run. For Apple and LinkedIn that message names
auth-apple or auth-linkedin, which do not exist: install jose with
warlock add auth-google or npm install jose instead. Run migrations after installing: authMigrations
now includes provider_accounts and passkey_credentials, and
one_time_tokens gained an attempts column.
providers: { google: { clientId: env("GOOGLE_CLIENT_ID"), clientSecret: env("GOOGLE_CLIENT_SECRET"), redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google // scopes: ["openid", "email", "profile"], }, // emailField: "email", // createUser: async (profile, Model) => Model.create({ ... }),},import { completeProviderLogin, startProviderLogin } from "@warlock.js/auth";
router.get("/auth/google", async ({ response }) => response.redirect(await startProviderLogin(response, "google")),);
router.get("/auth/google/callback", async ({ request, response }) => { const { tokens } = await completeProviderLogin(User, "google", request, response); authService.setAuthCookie(response, tokens.accessToken); return response.redirect("/");});The button is a plain link — it must be a top-level navigation, not a
fetch, so the state cookie goes out and comes back:
<a href="/auth/google">Continue with Google</a>state,nonce, and the PKCE verifier live in a signed, 10-minute, HttpOnlyauth_provider_statecookie (SameSite=Lax, so it survives Google’s redirect back). The callback clears the cookie, so a started login can complete only once.- The callback throws
InvalidProviderCallbackError(400,EC009) for a missing, forged, or expired cookie; astatemismatch; a?error=from Google; a failed code exchange; or an id_token with a bad signature,iss,aud,exp, ornonce. The response message stays generic;error.reasonsays which. - Linking. An existing
provider_accountsrow (provider + Googlesub) always decides the user. Without one, the Google email must be verified (email_verified: true), or the call throwsProviderEmailNotVerifiedError(403,EC010) and nothing is linked or created. A verified email links the matching user byauth.providers.emailField, or creates one with{ email, name, emailVerifiedAt: now }. - For another OIDC provider, implement
AuthProvider(authorizationUrl(state),handleCallback({ query, expected })) and register it underauth.providers.custom.<name>.
GitHub, Discord, LinkedIn, Facebook, X
Section titled “GitHub, Discord, LinkedIn, Facebook, X”These providers work the same way as Google. Add a config block under
auth.providers.<name>, a GET route that redirects to
startProviderLogin(response, "<name>"), and a GET callback that calls
completeProviderLogin(User, "<name>", request, response). The state cookie,
the rejections (EC009), and the linking rules (EC010) are the same too.
providers: { github: { clientId: env("GITHUB_CLIENT_ID"), clientSecret: env("GITHUB_CLIENT_SECRET"), redirectUri: `${env("APP_URL")}/auth/github/callback`, // scopes: ["read:user", "user:email"], }, discord: { clientId: env("DISCORD_CLIENT_ID"), clientSecret: env("DISCORD_CLIENT_SECRET"), redirectUri: `${env("APP_URL")}/auth/discord/callback`, // scopes: ["identify", "email"], }, linkedin: { clientId: env("LINKEDIN_CLIENT_ID"), clientSecret: env("LINKEDIN_CLIENT_SECRET"), redirectUri: `${env("APP_URL")}/auth/linkedin/callback`, // scopes: ["openid", "profile", "email"], }, facebook: { clientId: env("FACEBOOK_CLIENT_ID"), clientSecret: env("FACEBOOK_CLIENT_SECRET"), redirectUri: `${env("APP_URL")}/auth/facebook/callback`, // scopes: ["email", "public_profile"], }, x: { clientId: env("X_CLIENT_ID"), clientSecret: env("X_CLIENT_SECRET"), redirectUri: `${env("APP_URL")}/auth/x/callback`, // scopes: ["tweet.read", "users.read"], },},import { completeProviderLogin, startProviderLogin } from "@warlock.js/auth";
for (const provider of ["github", "discord", "linkedin", "facebook", "x"]) { router.get(`/auth/${provider}`, async ({ response }) => response.redirect(await startProviderLogin(response, provider)), );
router.get(`/auth/${provider}/callback`, async ({ request, response }) => { const { tokens } = await completeProviderLogin(User, provider, request, response); authService.setAuthCookie(response, tokens.accessToken); return response.redirect("/"); });}| Provider | Protocol | Where the email comes from |
|---|---|---|
| GitHub | OAuth 2 + PKCE | /user/emails, because /user.email is null unless the user made it public. Only an address that is both primary and verified is used. Otherwise the profile has no email. |
| Discord | OAuth 2 + PKCE | /users/@me. The email counts as verified only when Discord’s verified flag is true. |
| OpenID Connect + PKCE | The id_token, checked with jose against LinkedIn’s JWKS (signature, issuer, audience, expiry, nonce). email_verified must be the boolean true. | |
| OAuth 2 | Graph /me?fields=id,name,email,picture. Facebook returns only confirmed addresses, so an email that is present counts as verified. Without the email permission, the profile has no email. | |
| X | OAuth 2 + PKCE, with HTTP Basic client auth at the token endpoint | None. /2/users/me never returns an email. |
A profile with no verified email can log in only through an existing
provider_accounts link. Otherwise it is rejected with
ProviderEmailNotVerifiedError, and auth never makes up an address. On X,
this applies to every first login, so create the link another way before the
first X login.
Provider names are looked up only by the object’s own keys. An inherited key
such as toString never resolves to a provider.
providers: { apple: { clientId: env("APPLE_CLIENT_ID"), // the Services ID teamId: env("APPLE_TEAM_ID"), keyId: env("APPLE_KEY_ID"), privateKey: env("APPLE_PRIVATE_KEY"), // the .p8 file's PKCS8 PEM contents redirectUri: `${env("APP_URL")}/auth/apple/callback`, // scopes: ["name", "email"], },},router.get("/auth/apple", async ({ response }) => response.redirect(await startProviderLogin(response, "apple")),);
// Apple POSTs the callback (response_mode=form_post) when name/email scopes are requested.router.post("/auth/apple/callback", async ({ request, response }) => { const { tokens } = await completeProviderLogin(User, "apple", request, response); authService.setAuthCookie(response, tokens.accessToken); return response.redirect("/");});- The callback is a POST. Apple sends
code,state, anduseras anapplication/x-www-form-urlencodedbody. Core parses that body type, sorequest.input()reads the values. See HTTP request. - The state cookie needs HTTPS. A cross-site POST drops a
SameSite=Laxcookie. So for Apple, the state cookie is writtenSameSite=None; Secure. Apple requires HTTPS in production anyway. For local development, usehttps://orhttp://localhost, which browsers treat as secure. - Apple has no static client secret. On every callback, auth signs a new
ES256 JWT with your
.p8key:issis the team ID,subis the client ID,audis Apple, andkidis the key ID. - Apple sends the account name only on the first authorization, as a
userform field ({"name":{"firstName","lastName"}}). Save it then, because later logins don’t include it. - The email may be a private-relay address (
@privaterelay.appleid.com). It is a real, working address and is accepted like any other. - Rejections and linking work the same way as for Google.
Passkeys
Section titled “Passkeys”passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },import { generatePasskeyAuthenticationOptions, generatePasskeyRegistrationOptions, verifyPasskeyAuthentication, verifyPasskeyRegistration,} from "@warlock.js/auth";import { authMiddleware } from "@warlock.js/auth";
// Register (logged in)router.post( "/auth/passkeys/register/options", async ({ request, response }) => response.success(await generatePasskeyRegistrationOptions(request.locals.user)), { middleware: [authMiddleware("user")] },);router.post( "/auth/passkeys/register", async ({ request, response }) => { await verifyPasskeyRegistration(request, request.locals.user, request.input("credential")); return response.success({ registered: true }); }, { middleware: [authMiddleware("user")] },);
// Log inrouter.post("/auth/passkeys/login/options", async ({ response }) => response.success(await generatePasskeyAuthenticationOptions()),);router.post("/auth/passkeys/login", async ({ request, response }) => { const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential")); authService.setAuthCookie(response, tokens.accessToken); return response.success({ ok: true });});Browser side, with @simplewebauthn/browser:
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
const post = (url: string, body?: unknown) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }) .then((r) => r.json());
// registerconst regOptions = await post("/auth/passkeys/register/options");await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
// log inconst authOptions = await post("/auth/passkeys/login/options");await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });- Challenges live in
one_time_tokensas SHA-256 hashes, expire after 5 minutes (auth.passkeys.challengeExpiresIn), and are consumed before verification — a replayed challenge fails, and so does one whose first verification failed. - The request
Originmust be one ofauth.passkeys.origin. The same list is checked against the signedclientDataJSON. - The signature counter must advance; one that did not move past the stored value (when either is non-zero) is rejected as a cloned authenticator. The new counter is saved with a compare-and-set.
- Every rejection throws
InvalidPasskeyError(400,EC011) with areason.
Phone code (OTP)
Section titled “Phone code (OTP)”otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },import { otpRequestThrottleMiddleware, otpVerifyThrottleMiddleware, requestOtp, verifyOtp } from "@warlock.js/auth";
router.post( "/auth/otp/request", async ({ request, response }) => { await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms", }); return response.success({ message: "If that number is registered, a code is on its way." }); }, { middleware: [otpRequestThrottleMiddleware()] }, // 3 / 1h per phone + IP);
router.post( "/auth/otp/verify", async ({ request, response }) => { const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code")); authService.setAuthCookie(response, tokens.accessToken); return response.success({ ok: true }); }, { middleware: [otpVerifyThrottleMiddleware()] }, // 5 failures / 15m per phone + IP);- Codes are 6 digits, stored in
one_time_tokens(purposeotp) as a salted HMAC keyed from your access-token secret — never plain SHA-256, which is trivially reversible for 6 digits. A new request invalidates the previous code. - Every verify counts an attempt atomically first. After
maxAttemptsthe code is invalidated even if the right code comes next, and even under concurrent guesses. - Delivery:
notify.channel(channel).send(phone, { body, code, expiresAt }). Register that channel inconfig/notifications.ts, or setauth.otp.send(phone, message, channel).auth.otp.message(code)changes the text. Auth ships no SMS/WhatsApp driver. - Anti-enumeration: an unknown phone gets the same
requestOtpresult (nothing sent) and the sameInvalidOneTimeTokenError(400,EC008) fromverifyOtp. verifyOtpis a POST — the CSRF Origin check applies when the route is cookie-authenticated; otherwise there is no ambient credential to ride.
Gotchas
Section titled “Gotchas”completeProviderLoginneeds the same response object used bystartProviderLogin, to clear the state cookie.- One provider identity links to one account, of one user type.
auth.cleanuphard-deletes expired and consumedone_time_tokensrows — challenges and codes included.- A counter the SDK itself rejects is reported with
reason: "counter-regression", the same as auth’s own check.
Related
Section titled “Related”- Handle login and logout — password login,
setAuthCookie, and the shared token/cookie surface every method here ends through. - Protect routes — the CSRF Origin check for cookie-authenticated routes.