Skip to content

polaris/passwordless

Passwordless sign-in for Polaris for PHP: magic links, email one-time codes (to sign in, to verify the email, to reset the password), phone codes for a verified second contact, and one-time tokens that hand a session to another device or domain. Every method ends in core’s login envelope, every secret works once, and an unknown address or number gets the same answer as a known one.

Terminal window
composer require polaris/passwordless
use Polaris\Audit\AuditPlugin;
use Polaris\Messaging\MessagingPlugin;
use Polaris\Passwordless\PasswordlessPlugin;
$polaris = Polaris::create(new Config(..., plugins: [
new AuditPlugin(),
new MessagingPlugin(channels: [$mail, $sms]),
new PasswordlessPlugin(baseUrl: 'https://app.example.com/auth', redirectUris: ['https://app.example.com/signed-in']),
]));

polaris/messaging sends the links and codes (its email.magic_link, email.otp and sms.otp templates, its channels and its per-recipient caps) and needs polaris/audit; register both first. baseUrl is where Polaris is mounted, prefix included: the magic link points at baseUrl/magic-link/verify. In Laravel, Symfony and Yii the instances go in the adapter’s plugins configuration; the two tables (polaris_passwordless_secret, polaris_passwordless_phone) join polaris:install, schema:create and schema:diff, the routes join the route table.

MethodamrFlow
Magic linkmagic_linkPOST /magic-link/send {email, redirect_uri?} emails a link valid once for 15 minutes; the link (GET /magic-link/verify?token=) signs the user in and answers 302 <redirect_uri>?code=...; POST /magic-link/exchange {code} answers the envelope, once, within a minute. Tokens never travel in the application’s URL.
Email codeemail_otpPOST /email-otp/send {email} emails a 6-digit code; POST /email-otp/verify {email, code} answers the envelope.
Phone codephonePOST /phone/send {phone} texts a code to a user’s verified phone; POST /phone/verify {phone, code} answers the envelope. A signed-in user adds the phone first: POST /phone/add {phone}, then POST /phone/confirm {phone, code}.
One-time tokenthe generating session’sPOST /one-time-token/generate (bearer) answers a token valid once for 3 minutes; POST /one-time-token/verify {token}, on the other device or domain, answers a new session with the first one’s amr, mfa, auth_time and organization.

The email code also has two purposes beside signing in, as code alternatives to core’s link flows (which stay as they are): POST /email-otp/send {email, purpose: "verify-email"} then POST /email-otp/verify-email {email, code}, and purpose: "reset-password" then POST /email-otp/reset-password {email, code, password}, which checks core’s password policy, lifts a lockout and ends every session, as core’s reset does. A code is bound to its purpose and to its address.

  • Same answer: every send answers 202 {data: {status: "sent"}} whether or not anything was sent; a wrong code and an unknown address or number answer the same 422 passwordless/code_invalid.
  • Single use: a secret is spent by a conditional update, so two concurrent verifies cannot both win; an expired or spent link, code or token is refused.
  • Attempts: every guess reserves one of 5 attempts atomically, so parallel guesses cannot exceed them; a new code for the same address and purpose replaces the previous one and inherits its attempts while it lives, so sending again does not reset the count (after 5 wrong guesses, a code works again once the last one has expired).
  • Stored hashed: links, codes, tokens and the addresses they were sent to are keyed hashes under core’s pepper, never the values.
  • MFA: a user with a confirmed MFA factor gets core’s mfa_required ticket instead of a session and completes it at /auth/mfa/verify (respectMfa: false turns this off). A one-time token skips the gate: the session it transfers already passed it.
  • Sign-up: an unknown email that proves its mailbox (magic link, email code) becomes a user, verified and without a password, unless signUp: false; then it gets nothing and the same answer. An unverified user who proves the mailbox by signing in becomes verified and loses the password set at registration (nobody had proved the mailbox for it; the owner sets one by reset). A phone never signs anybody up.
  • One-time tokens die with the session that generated them.
  • Adding a phone is a step_up route: a user with MFA and a stale auth_time steps up first.
RouteWhoRate limit
POST /magic-link/send {email, redirect_uri?}publicmfa_send
GET /magic-link/verify?token=public (the email’s link)token_consume
POST /magic-link/exchange {code}publictoken_consume
POST /email-otp/send {email, purpose?}publicmfa_send
POST /email-otp/verify {email, code}publictoken_consume
POST /email-otp/verify-email {email, code}publictoken_consume
POST /email-otp/reset-password {email, code, password}publictoken_consume
POST /phone/send {phone}publicmfa_send
POST /phone/verify {phone, code}publictoken_consume
POST /phone/add {phone}bearermfa_send
POST /phone/confirm {phone, code}bearertoken_consume
POST /one-time-token/generatebearer (a live session)
POST /one-time-token/verify {token}publictoken_consume

The rate limits are core’s groups, per IP. Errors are problem documents: passwordless/invalid_input (with errors), passwordless/redirect_not_allowed, passwordless/code_invalid, passwordless/token_invalid, passwordless/password_invalid, passwordless/account_disabled, passwordless/phone_taken, passwordless/session_required. The TypeScript client has every route as client.passwordless.*.

With polaris/sentinel, add the plugin’s routes to sentinel’s guarded list so it judges the sends and the code sign-ins as it judges core’s:

new SentinelPlugin(routes: SentinelMiddleware::ROUTES + PasswordlessPlugin::SENTINEL_ROUTES);
new PasswordlessPlugin(
baseUrl: 'https://app.example.com/auth',
redirectUris: ['https://app.example.com/signed-in'], // exact URLs a magic link may end on; the first is the default
signUp: true, // an unknown email that proves its mailbox becomes a user
respectMfa: true, // core's MFA gate applies to magic links, email and phone codes
magicLinkTtl: 900, // seconds
otpTtl: 300,
otpLength: 6, // 6 to 10 digits
maxAttempts: 5,
oneTimeTokenTtl: 180,
);

The magic link’s hand-off code lives a minute in the graph’s cache: share it across web nodes as you share core’s rate store. Sends are as synchronous as messaging’s outbox: with a slow channel, a queued outbox keeps a known address from answering measurably later than an unknown one.

MIT. Polaris for PHP is created and maintained by 2am.tech.