Authentication
Phone codes
Use PhoneOtp to sign in existing accounts with an SMS code.
For managed or application-declared SQL storage, the composed persistence Layer supplies phone and proof services together. The explicit mappings below also cover number lifecycle operations and specialized storage layouts.
The snippets below are local calls inside existing Effect request handlers, with
AppAuth and the HTTP request boundary provided. Publish selected methods through
a shared contract for browser clients.
Enable phone sign-in
Section titled “Enable phone sign-in”import { Schema } from "effect";import { Auth, PhoneOtp, Sessions } from "@yielded/auth";
export const AppAuth = Auth.make("app/Auth", { claims: Schema.Struct({ phoneNumber: PhoneOtp.PhoneNumber }), sessions: Sessions.stateful(), strategies: { phone: PhoneOtp.make(), }, defaultStrategy: "phone",});The default code has six digits and expires after five minutes. Override digits
or policy only to change that behavior. Keys and delivery come from Layers.
Send a code
Section titled “Send a code”With Effect imported from effect, supply the request’s host-verified network key:
const auth = yield* AppAuth;const started = yield* auth.signIn({ phoneNumber, locale: "en" }).pipe( Effect.provideService(PhoneOtp.PhoneRequestContext, { networkKey }),);networkKey is a Redacted<string> used for admission limits. Derive it on the
server per request; the HTTP auth adapter does not supply it.
Use an international number such as +14155550123. The result contains a flowId
and proof reference. The request binder goes through private credential delivery;
your SMS service receives the code.
Complete sign-in
Section titled “Complete sign-in”Use the original flowId and reference. The server resolves requestBinding
from private request credentials. In a shared action, map it with
requestFields: { requestBinding: "request-binding" }; the named server and client
calls then take only the public fields.
const auth = yield* AppAuth;const result = yield* auth.completeSignIn({ flowId, phoneNumber, requestBinding, reference, code,}).pipe( Effect.provideService(PhoneOtp.PhoneRequestContext, { networkKey }),);signIn({ phoneNumber }) → SMS + private request binder → user enters code → completeSignIn(original flow, binder, reference, code) → session or additional-factor resultA consumed code cannot be reused if session issuance subsequently fails. Request a new code. SMS proves possession; it does not provide phishing resistance.
Supply the services
Section titled “Supply the services”The strategy handles code generation and verification. Supply these implementations:
| Layer or service | What it does | Where it comes from |
|---|---|---|
PhonePersistenceLive |
Finds the account for a phone number and enforces admission limits. | Your database, using a library adapter below. |
ProofPersistenceLive |
Stores code digests, expiry, failed attempts, and consumption. | Your database, using a library adapter below. |
PhoneDeliveryEligibility |
Decides which destination numbers you support. | Your application policy. |
SessionClaims |
Returns the session fields declared in AppAuth.claims. |
Your application. |
SmsDelivery |
Sends the message. | Twilio.layer or another transport. |
PhonePersistenceLive and ProofPersistenceLive are names for the Layers you build
below, not package exports. The adapters implement the storage operations; you
supply your table mappings and migrations.
Build the database Layers — SQLite on Bun
import { Effect, Layer } from "effect";import { phonePersistenceLayer } from "@yielded/auth-persistence-drizzle";import { databaseLayer, makePhonePersistenceServices, makeProofPersistenceServices,} from "@yielded/auth-persistence-drizzle/SqliteBun";import { Proofs } from "@yielded/auth";
import * as SqliteClient from "@effect/sql-sqlite-bun/SqliteClient";import { phoneMapping, proofMapping } from "./schema";
const DatabaseLive = databaseLayer.pipe( Layer.provide(SqliteClient.layer({ filename: "auth.sqlite" })),);
export const PhonePersistenceLive = phonePersistenceLayer( makePhonePersistenceServices(phoneMapping),).pipe(Layer.provide(DatabaseLive));
export const ProofPersistenceLive = Layer.effect( Proofs.ProofPersistence, Effect.gen(function* () { const services = yield* makeProofPersistenceServices(proofMapping); return services.proofPersistence; }),).pipe(Layer.provide(DatabaseLive));The database connection above uses SQLite on Bun. The phone Layer supplies
PhonePersistence, PhoneAdmission, and PhoneSignInTargets, with overridable
Web Crypto and empty hook defaults. The proof Layer stores challenges, consumption,
and rate limits. Sign-in uses lookup and admission; number-management operations
also use PhonePersistence. You provide table mappings and
migrations. See the SQLite example
for the table definitions and mappings.
Wire those Layers with a sending policy and session claims:
import { Config, Effect, Layer } from "effect";import { PhoneOtp } from "@yielded/auth";import * as Twilio from "@yielded/auth/adapters/Twilio";import { FetchHttpClient } from "effect/http";
import { AppAuth } from "./auth";import { AuthDependencies } from "./auth-dependencies";import { PhonePersistenceLive, ProofPersistenceLive } from "./auth-persistence";
const TwilioConfigLive = Layer.effect( Twilio.TwilioConfig, Config.all({ accountSid: Config.String("TWILIO_ACCOUNT_SID"), authToken: Config.Redacted("TWILIO_AUTH_TOKEN"), from: Config.String("TWILIO_FROM"), }),);
const SmsLive = Twilio.layer.pipe( Layer.provide(TwilioConfigLive), Layer.provide(FetchHttpClient.layer),);
export const PhoneLive = Layer.mergeAll( PhonePersistenceLive, ProofPersistenceLive, Layer.succeed(PhoneOtp.PhoneDeliveryEligibility, { allowed: (number) => Effect.succeed(number.startsWith("+1")), }), Layer.succeed(AppAuth.strategies.phone.SessionClaims, { resolve: ({ credential }) => Effect.succeed({ phoneNumber: credential.phoneNumber }), }), SmsLive,);
export const AuthLive = AppAuth.layer.pipe( Layer.provide(PhoneLive), Layer.provide(AuthDependencies),);This example allows +1 destinations and stores the verified phone number
in the session. Replace the prefix check with your supported destinations. To add
account fields to claims, accept resolve: ({ subjectId }) => … and query your account by that ID and return the fields declared in AppAuth.claims.
TwilioConfigLive loads credentials from Effect Config; use messagingServiceSid
instead of from for a Twilio Messaging Service. The adapter uses Effect HTTP and
requires no Twilio SDK.
AuthDependencies is defined in the shared application composition.
It supplies session storage, account authority, request-binding configuration, and
ProofKeys. Web Crypto, empty lifecycle hooks, and a bounded delivery worker have
defaults. Database storage, destination policy, claims, and delivery have no automatic
implementations. Build Auth in an application scope that outlives requests; the built-in
worker keeps SMS provider latency outside the response path without extra wiring.
See delivery lifetime and overrides, which also apply to SMS.
Customize the SMS message
Add this optional Layer to PhoneLive:
PhoneOtp.Template.layer({ render: (code) => `Your Acme sign-in code is ${code}.`,});The default is Your sign-in code is 123456. The renderer also receives locale
and expiry as its second argument. Text stays private through delivery.
Register or change phone numbers
Enable lifecycle operations on the same strategy:
phone: PhoneOtp.make({ lifecycle: true });Use a lifecycle policy object instead of true to customize its behavior.
These operations additionally require PhonePersistence and PhoneActionEvidence.
The Drizzle Layer above already supplies PhonePersistence. Add independent
authorization to PhoneLive:
Layer.succeed(PhoneOtp.PhoneActionEvidence, { verify: authorizePhoneChange });authorizePhoneChange is your application’s check of independent evidence for
a number change. Start with auth.begin(input) and finish with
auth.completeLifecycle(input). Supply PhoneRequestContext
for each request, as for sign-in.
Do not link accounts because their phone strings match. See the complete phone composition.