The passwordless authentication feature provides a secure and user-friendly method for users to sign in to your application without needing a password. This feature leverages magic links sent via email, along with the option for a fallback OTP (One-Time Password) mechanism, to enhance security and usability. Users can seamlessly log in by simply clicking a link in their email, or if they open the link in a different browser or device, they can use a client-generated OTP to complete the login process.

Flow Overview

  1. Configuration:
  • Administrators must first configure the passwordless authentication settings via the GraphQL API.
  • The configuration includes enabling the feature, setting the expiration time for magic links, and specifying the URL that will be included in the email.
  • There is also an option to override the default email template with a new PASSWORDLESS_SIGN_IN mail type.
  1. Initiating Sign-In:
  • The user begins the sign-in process by entering their email address.
  • The system sends a magic link to the user's email, which contains a unique token with a default expiration of 5 minutes.
  • The user is prompted to check their inbox for the magic link.
  1. Handling the Magic Link:
  • If the user clicks the magic link in the same browser where they initiated the request, they are automatically logged in.
  • If the link is opened in a different browser or device, an OTP flow is activated:
    • A 6-digit (configurable) OTP is generated on the client-side and displayed to the user.
    • The original token becomes invalid once the OTP is generated, meaning it cannot be reused for automatic sign-in or for generating a new OTP.
    • The user copies the OTP and enters it into the original browser where they started the login process.
    • Users are allowed a maximum of 3 attempts to enter the correct OTP.
  1. Multi-Factor Authentication (MFA):
  • If MFA is enabled for the user, they will be prompted to complete MFA authentication after the magic link or OTP validation.
  1. Rate Limits and Security:
  • Token validity period (default 5 minutes) and a limit of 3 OTP attempts per magic link.
  • (since 2.2) Per-IP rate limit on initSignInPasswordless via rateLimits.passwordlessInitPerIp. See anti-abuse.
  • (since 2.2) Per-email exponential backoff on initSignInPasswordless reusing the login.baseBackoff / login.maxBackoff / login.attemptWindow knobs — a single inbox cannot be flooded with magic-link mails. The mutation still returns ok: true but the mail is suppressed until the next allowed attempt.
  • (since 2.2) Optional captcha verification on initSignInPasswordless when captcha is configured. Pass the token via the captchaToken argument.
  • (since 2.2) When login.revealUserExists: false, an initSignInPasswordless for an unknown email returns PASSWORDLESS_DISABLED rather than PERSON_NOT_FOUND.

GraphQL API

Below is an overview of the key mutations and types involved:

Configuration

Mutation: configure

  • Purpose: Configure passwordless authentication settings.
  • Input: ConfigInput containing passwordless settings such as enabling the feature, specifying the email URL, and setting the expiration time.
  • Response: ConfigureResponse indicating success or failure (in case of invalid input).
mutation {
  configure(config: {
    passwordless: {
      enabled: always,
      url: "https://example.com/auth",
      expiration: "PT5M"
    }
  }) {
    ok
    error {
      code
      developerMessage
    }
  }
}

Initiating Sign-In

Mutation: initSignInPasswordless

  • Purpose: Initiate the passwordless sign-in process by sending a magic link to the user's email.
  • Input: The user's email and optional settings such as email template variants.
  • Response: InitSignInPasswordlessResponse containing a request ID and expiration time.
mutation {
  initSignInPasswordless(
    email: "[email protected]",
    captchaToken: "0.aXR…"
  ) {
    ok
    error {
      code
      developerMessage
    }
    result {
      requestId
      expiresAt
    }
  }
}

Error codes: PERSON_NOT_FOUND (or PASSWORDLESS_DISABLED when revealUserExists: false), INVALID_CAPTCHA (since 2.2), RATE_LIMIT_EXCEEDED (since 2.2).

Mutation: signInPasswordless

  • Purpose: Complete the sign-in process using the magic link or OTP.
  • Input: The requestId, validationType (either otp or token), the token from the magic link or OTP code, and optionally the MFA OTP.
  • Response: SignInPasswordlessResponse indicating success or failure, with details of the signed-in user if successful.
mutation {
  signInPasswordless(requestId: "abcd1234", validationType: token, token: "xyz789", expiration: 5) {
    ok
    error {
      code
      developerMessage
    }
    result {
      token
      person {
        id
        email
      }
    }
  }
}

Activating Passwordless OTP

Mutation: activatePasswordlessOtp

  • Purpose: Exchange a long token for a short OTP, which will be shown to the user for manual entry.
  • Input: The requestId, the long token, and the otpHash generated from the client-side OTP.
  • Response: ActivatePasswordlessOtpResponse indicating success or failure.
mutation {
  activatePasswordlessOtp(requestId: "abcd1234", token: "xyz789", otpHash: "hashedOtpValue") {
    ok
    error {
      code
      developerMessage
    }
  }
}

Completing sign-in with MFA

If 2FA is enabled for the person, signInPasswordless requires the current TOTP code via the mfaOtp argument. A missing or wrong code is reported the same way as on signIn:

mutation {
  signInPasswordless(
    requestId: "abcd1234",
    validationType: token,
    token: "xyz789",
    mfaOtp: "123456"
  ) {
    ok
    error { code }
    result { token person { id email } }
  }
}

Error codes added in this case: OTP_REQUIRED (no mfaOtp supplied), INVALID_OTP_TOKEN (wrong code). React to OTP_REQUIRED by prompting the user and retrying with mfaOtp populated. See two-factor for the broader 2FA reference.

Per-person opt-in / opt-out

When passwordless.enabled is optIn or optOut (see configuration), each person can flip their own preference with two self-service mutations:

mutation { enableMyPasswordless  { ok error { code } } }
mutation { disableMyPasswordless { ok error { code } } }
CodeCause
NOT_A_PERSONCaller is authenticated via a permanent API key, not a person.
CANNOT_TOGGLEpasswordless.enabled is always or never — the tenant has forced the setting globally and individual persons cannot override it.

The mutations are gated by the PERSON_TOGGLE_PASSWORDLESS permission, which is held by PERSON (any authenticated person) by default. The current state is observable through me { person { passwordlessEnabled } }.