Passwordless login

Instead of a password, a user enters their email, receives a 6-digit code, and types it back. Two calls: one to send the code, one to spend it.

This is off by default. Turn it on per workspace with authPolicy:

authPolicyWhat the sign-in screen accepts
PasswordOnlyPassword only. The default.
PasswordAndPasswordlessEither.
PasswordlessOnlyEmailed code only; passwords refused.
curl -X PATCH https://api.passura.dev/v1/acme/tenant \
  -H "Authorization: Bearer <adminAccessToken>" \
  -H "content-type: application/json" \
  -d '{ "authPolicy": "PasswordAndPasswordless" }'

Sending the code

curl -X POST https://api.passura.dev/v1/acme/auth/request-login-code \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]" }'
{ "ok": true }

This response is always { "ok": true } — for a real account, an unknown address, and a suspended user alike. That is deliberate: an endpoint that answered differently would let anyone test which email addresses have accounts in your workspace. Your sign-in screen should say "if that address has an account, a code is on its way" rather than confirming anything.

The code is valid for 10 minutes, survives 5 wrong guesses before it burns, and requesting a new one immediately invalidates the previous one.

Rate limits are enforced on two independent buckets: 3 requests per 15 minutes for a given email address, and 10 per minute from a given IP. The first stops one inbox from being flooded; the second stops one host from spraying many.

Spending the code

curl -X POST https://api.passura.dev/v1/acme/auth/verify-login-code \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "code": "482913" }'

On success this returns the same token envelope as /auth/login. A wrong or expired code returns 401 with details.reason of invalid_code — the same answer an unknown address gets, for the same reason as above.

Interaction with MFA

An emailed code proves the user controls the inbox, which makes it a first factor. Your workspace's MFA policy still applies on top of it.

In a workspace with mfaPolicy: "Required", a correct code comes back as:

{
  "code": "UNAUTHORIZED",
  "message": "MFA required",
  "details": { "reason": "mfa_required", "methods": ["totp"] }
}

The code is not consumed by that response. Retry the same request with the authenticator's code alongside it:

curl -X POST https://api.passura.dev/v1/acme/auth/verify-login-code \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "code": "482913", "totpCode": "123456" }'

email is not offered as a second factor here — a second emailed code would prove nothing the first one didn't.

A user who has never enrolled TOTP gets details.reason: "mfa_not_enrolled". They can enroll using their login code in place of a password, which is the only way for someone in a PasswordlessOnly workspace to get through:

curl -X POST https://api.passura.dev/v1/acme/auth/totp/initial-enroll \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "loginCode": "482913" }'

Then /auth/totp/initial-verify with the same loginCode plus the code from the authenticator. Pass exactly one of password or loginCode to either call.

Accounts on first sign-in

By default only existing users can request a code; an unknown address silently does nothing. Setting passwordlessJitSignup: true turns the sign-in screen into a registration form — anyone who can reach it and receives a code at the address they typed becomes a member.

curl -X PATCH https://api.passura.dev/v1/acme/tenant \
  -H "Authorization: Bearer <adminAccessToken>" \
  -H "content-type: application/json" \
  -d '{ "passwordlessJitSignup": true }'

Users created this way start as Pending and become Active on their first verified code. Leave this off unless open registration is what you want.

What your login screen needs to know

Which methods a workspace accepts is readable without a session:

curl https://api.passura.dev/v1/acme/auth/config
{ "authPolicy": "PasswordAndPasswordless", "googleOauthEnabled": true }

Audit trail

Passwordless sign-ins record PasswordlessCodeSent, PasswordlessCodeVerified, and PasswordlessCodeFailed, plus the usual UserLogin with metadata.via: "passwordless". They are kept separate from the MfaEmailOtp* actions so the log doesn't file a first-factor sign-in under "MFA".