Magic link login
There are three parts to Magic link based login:
- Creating and sending the magic link to the user.
- Allowing the user to resend a (new) magic link if they want.
- Consuming the link (when clicked) to login the user.
note
The same flow applies during sign up and sign in. If the user is signing up, the createdNewUser
boolean on the frontend and backend will be true
(as the result of the consume code API call).
#
Step 1: Sending the Magic linkSuperTokens allows you to send a magic link to a user's email or phone number. You have already configured this setting on the backend SDK init
function call in "Initialisation" section.
Start by making a form which asks the user for their email or phone, and then call the following API to create and send them a magic link
- Web
- Mobile
- Via NPM
- Via Script Tag
import { createCode } from "supertokens-web-js/recipe/passwordless";
async function sendMagicLink(email: string) {
try {
let response = await createCode({
email
});
/**
* For phone number, use this:
let response = await createCode({
phoneNumber: "+1234567890"
});
*/
if (response.status === "SIGN_IN_UP_NOT_ALLOWED") {
// the reason string is a user friendly message
// about what went wrong. It can also contain a support code which users
// can tell you so you know why their sign in / up was not allowed.
window.alert(response.reason)
} else {
// Magic link sent successfully.
window.alert("Please check your email for the magic link");
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you,
// or if the input email / phone number is not valid.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
}
async function sendMagicLink(email: string) {
try {
let response = await supertokensPasswordless.createCode({
email
});
/**
* For phone number, use this:
let response = await supertokensPasswordless.createCode({
phoneNumber: "+1234567890"
});
*/
if (response.status === "SIGN_IN_UP_NOT_ALLOWED") {
// the reason string is a user friendly message
// about what went wrong. It can also contain a support code which users
// can tell you so you know why their sign in / up was not allowed.
window.alert(response.reason)
} else {
// Magic link sent successfully.
window.alert("Please check your email for the magic link");
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you,
// or if the input email / phone number is not valid.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
}
- Single tenant setup
- Multi tenant setup
For email based login
curl --location --request POST '<YOUR_API_DOMAIN>/auth/signinup/code' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"email": "[email protected]"
}'
For phone number based login
curl --location --request POST '<YOUR_API_DOMAIN>/auth/signinup/code' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"phoneNumber": "+1234567890"
}'
For email based login
curl --location --request POST '<YOUR_API_DOMAIN>/auth/<TENANT_ID>/signinup/code' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"email": "[email protected]"
}'
For phone number based login
curl --location --request POST '<YOUR_API_DOMAIN>/auth/<TENANT_ID>/signinup/code' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"phoneNumber": "+1234567890"
}'
The response body from the API call has a status
property in it:
status: "OK"
: This means that the magic link was successfully sent.status: "GENERAL_ERROR"
: This is possible if you have overriden the backend API to send back a custom error message which should be displayed on the frontend, or if the input email or password failed the backend validation logic.status: "SIGN_IN_UP_NOT_ALLOWED"
: This can happen during automatic account linking or during MFA. Thereason
prop that's in the response body contains a support code using which you can see why the sign in / up was not allowed.
The response from the API call is the following object (in case of status: "OK"
):
{
status: "OK";
deviceId: string;
preAuthSessionId: string;
flowType: "USER_INPUT_CODE" | "MAGIC_LINK" | "USER_INPUT_CODE_AND_MAGIC_LINK";
fetchResponse: Response; // raw fetch response from the API call
}
You want to save the deviceId
and preAuthSessionId
on the frontend storage. These will be useful to:
- Resend a new magic link.
- Detect if the user has already sent a magic link before or if this is an entirely new login attempt. This distinction can be important if you have different UI for these two states. For example, if this info already exists, you do not want to show the user an input box to enter their email / phone, and instead want to show them the resend link button.
#
Changing the magic link URL, or deep linking it to your appBy default, the magic link will point to the websiteDomain
that is configured on the backend, on the /auth/verify
route (where /auth
is the default value of websiteBasePath
).
If you want to change this to a different path, a different domain, or deep link it to your mobile / desktop app, then you can do so on the backend in the following way:
- NodeJS
- GoLang
- Python
- Other Frameworks
Important
import SuperTokens from "supertokens-node";
import Passwordless from "supertokens-node/recipe/passwordless";
import Session from "supertokens-node/recipe/session";
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
recipeList: [
Passwordless.init({
contactMethod: "EMAIL", // This example will work with any contactMethod
// This example works with the "USER_INPUT_CODE_AND_MAGIC_LINK" and "MAGIC_LINK" flows.
flowType: "USER_INPUT_CODE_AND_MAGIC_LINK",
emailDelivery: {
override: (originalImplementation) => {
return {
...originalImplementation,
sendEmail: async function (input) {
return originalImplementation.sendEmail({
...input,
urlWithLinkCode: input.urlWithLinkCode?.replace(
// This is: `${websiteDomain}${websiteBasePath}/verify`
"http://localhost:3000/auth/verify",
"http://your.domain.com/your/path"
)
})
}
}
}
}
}),
Session.init({ /* ... */ })
]
});
import (
"strings"
"github.com/supertokens/supertokens-golang/ingredients/emaildelivery"
"github.com/supertokens/supertokens-golang/recipe/passwordless"
"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
"github.com/supertokens/supertokens-golang/supertokens"
)
func main() {
supertokens.Init(supertokens.TypeInput{
RecipeList: []supertokens.Recipe{
passwordless.Init(plessmodels.TypeInput{
EmailDelivery: &emaildelivery.TypeInput{
Override: func(originalImplementation emaildelivery.EmailDeliveryInterface) emaildelivery.EmailDeliveryInterface {
ogSendEmail := *originalImplementation.SendEmail
(*originalImplementation.SendEmail) = func(input emaildelivery.EmailType, userContext supertokens.UserContext) error {
// By default: `${websiteDomain}/${websiteBasePath}/verify`
newUrl := strings.Replace(
*input.PasswordlessLogin.UrlWithLinkCode,
"http://localhost:3000/auth/verify",
"http://localhost:3000/custom/path",
1,
)
input.PasswordlessLogin.UrlWithLinkCode = &newUrl
return ogSendEmail(input, userContext)
}
return originalImplementation
},
},
}),
},
})
}
from supertokens_python import init, InputAppInfo
from supertokens_python.recipe.passwordless.types import EmailDeliveryOverrideInput, EmailTemplateVars
from supertokens_python.recipe import passwordless
from typing import Dict, Any
from supertokens_python.ingredients.emaildelivery.types import EmailDeliveryConfig
def custom_email_deliver(original_implementation: EmailDeliveryOverrideInput) -> EmailDeliveryOverrideInput:
original_send_email = original_implementation.send_email
async def send_email(template_vars: EmailTemplateVars, user_context: Dict[str, Any]) -> None:
assert template_vars.url_with_link_code is not None
# By default: `${websiteDomain}/${websiteBasePath}/verify`
template_vars.url_with_link_code = template_vars.url_with_link_code.replace(
"http://localhost:3000/auth/verify", "http://localhost:3000/custom/path")
return await original_send_email(template_vars, user_context)
original_implementation.send_email = send_email
return original_implementation
init(
app_info=InputAppInfo(api_domain="...", app_name="...", website_domain="..."),
framework='...',
recipe_list=[
passwordless.init(
email_delivery=EmailDeliveryConfig(override=custom_email_deliver)
)
]
)
Multi Tenancy
For a multi tenant setup, the input to the sendEmail function will also contain the tenantId. You can use this to determine the correct value to set for the websiteDomain in the generated link.
#
Step 2: Resending a (new) Magic linkAfter sending the initial magic link to the user, you may want to display a resend button to them. When the user clicks on this button, you should call the following API
- Web
- Mobile
- Via NPM
- Via Script Tag
import { resendCode, clearLoginAttemptInfo } from "supertokens-web-js/recipe/passwordless";
async function resendMagicLink() {
try {
let response = await resendCode();
if (response.status === "RESTART_FLOW_ERROR") {
// this can happen if the user has already successfully logged in into
// another device whilst also trying to login to this one.
// we clear the login attempt info that was added when the createCode function
// was called - so that if the user does a page reload, they will now see the
// enter email / phone UI again.
await clearLoginAttemptInfo();
window.alert("Login failed. Please try again");
window.location.assign("/auth")
} else {
// Magic link resent successfully.
window.alert("Please check your email for the magic link");
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
}
async function resendMagicLink() {
try {
let response = await supertokensPasswordless.resendCode();
if (response.status === "RESTART_FLOW_ERROR") {
// this can happen if the user has already successfully logged in into
// another device whilst also trying to login to this one.
// we clear the login attempt info that was added when the createCode function
// was called - so that if the user does a page reload, they will now see the
// enter email / phone UI again.
await supertokensPasswordless.clearLoginAttemptInfo();
window.alert("Login failed. Please try again");
window.location.assign("/auth")
} else {
// Magic link resent successfully.
window.alert("Please check your email for the magic link");
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
}
- Single tenant setup
- Multi tenant setup
curl --location --request POST '<YOUR_API_DOMAIN>/auth/signinup/code/resend' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"deviceId": "...",
"preAuthSessionId": "...."
}'
curl --location --request POST '<YOUR_API_DOMAIN>/auth/<TENANT_ID>/signinup/code/resend' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"deviceId": "...",
"preAuthSessionId": "...."
}'
The response body from the API call has a status
property in it:
status: "OK"
: This means that the magic link was successfully sent.status: "RESTART_FLOW_ERROR"
: This can happen if the user has already successfully logged in into another device whilst also trying to login to this one. You want to take the user back to the login screen where they can enter their email / phone number again. Be sure to remove the storeddeviceId
andpreAuthSessionId
from the frontend storage.status: "GENERAL_ERROR"
: This is possible if you have overriden the backend API to send back a custom error message which should be displayed on the frontend.
#
How to detect if the user is in (Step 1) or in (Step 2) state?If you are building the UI for (Step 1) and (Step 2) on the same page, and if the user refreshes the page, you need a way to know which UI to show - the enter email / phone number form; or the resend magic link form.
- Web
- Mobile
- Via NPM
- Via Script Tag
import { getLoginAttemptInfo } from "supertokens-web-js/recipe/passwordless";
async function hasInitialMagicLinkBeenSent() {
return await getLoginAttemptInfo() !== undefined;
}
async function hasInitialMagicLinkBeenSent() {
return await supertokensPasswordless.getLoginAttemptInfo() !== undefined;
}
If hasInitialMagicLinkBeenSent
returns true
, it means that the user has already sent the initial magic link to themselves, and you can show the resend link UI (Step 2). Else show a form asking them to enter their email / phone number (Step 1).
Since you save the preAuthSessionId
and deviceId
after the initial magic link is sent, you can know if the user is in (Step 1) vs (Step 2) by simply checking if these tokens are stored on the device.
If they aren't, you should follow (Step 1), else follow (Step 2).
important
You need to clear these tokens if the user navigates away from the (Step 2) page, or if you get a RESTART_FLOW_ERROR
at any point in time from an API call, or if the user has successfully logged in.
#
Step 3: Consuming the Magic linkThis section talks about what needs to be done when the user clicks on the Magic link. There are two situations here:
- The user clicks the Magic link on the same browser & device as the one they had started the flow on.
- The user clicks the link on a different browser or device.
In order to detect which it is, you can do the following:
- Web
- Mobile
- Via NPM
- Via Script Tag
import { getLoginAttemptInfo } from "supertokens-web-js/recipe/passwordless";
async function isThisSameBrowserAndDevice() {
return await getLoginAttemptInfo() !== undefined;
}
async function isThisSameBrowserAndDevice() {
return await supertokensPasswordless.getLoginAttemptInfo() !== undefined;
}
Since you save the preAuthSessionId
and deviceId
, you can check if they exist on the app. If they do, then it's the same device that the user has opened the link on, else it's a different device.
#
If on the same device & browser- Web
- Mobile
On page load, you can consume the magic link by calling the following function
- Via NPM
- Via Script Tag
import { consumeCode, clearLoginAttemptInfo } from "supertokens-web-js/recipe/passwordless";
async function handleMagicLinkClicked() {
try {
let response = await consumeCode();
if (response.status === "OK") {
// we clear the login attempt info that was added when the createCode function
// was called since the login was successful.
await clearLoginAttemptInfo();
if (response.createdNewRecipeUser && response.user.loginMethods.length === 1) {
// user sign up success
} else {
// user sign in success
}
window.location.assign("/home")
} else {
// this can happen if the magic link has expired or is invalid
// or if it was denied due to security reasons in case of automatic account linking
// we clear the login attempt info that was added when the createCode function
// was called - so that if the user does a page reload, they will now see the
// enter email / phone UI again.
await clearLoginAttemptInfo();
window.alert("Login failed. Please try again");
window.location.assign("/auth")
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
}
async function handleMagicLinkClicked() {
try {
let response = await supertokensPasswordless.consumeCode();
if (response.status === "OK") {
// we clear the login attempt info that was added when the createCode function
// was called since the login was successful.
await supertokensPasswordless.clearLoginAttemptInfo();
if (response.createdNewRecipeUser && response.user.loginMethods.length === 1) {
// user sign up success
} else {
// user sign in success
}
window.location.assign("/home")
} else {
// this can happen if the magic link has expired or is invalid
// or if it was denied due to security reasons in case of automatic account linking
// we clear the login attempt info that was added when the createCode function
// was called - so that if the user does a page reload, they will now see the
// enter email / phone UI again.
await supertokensPasswordless.clearLoginAttemptInfo();
window.alert("Login failed. Please try again");
window.location.assign("/auth")
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
}
You need to extract the linkCode
and preAuthSessionId
from the Magic link. For example, if the Magic link is
https://example.com/auth/verify?preAuthSessionId=PyIwyA6VjdjNF5ggMV960rs3QXupRP2PEg2KcN5oi8s=#s4hxpBPnRC3xwBsCkFU228lh_CWe5HUBMRPowajsrgs=
Then the preAuthSessionId
is the value of the query param preAuthSessionId
(PyIwyA6VjdjNF5ggMV960rs3QXupRP2PEg2KcN5oi8s=
in the example), and the linkCode
is the part after the #
(s4hxpBPnRC3xwBsCkFU228lh_CWe5HUBMRPowajsrgs=
in our example).
We can then use these to call the consume API
- Single tenant setup
- Multi tenant setup
curl --location --request POST '<YOUR_API_DOMAIN>/auth/signinup/code/consume' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"linkCode": "s4hxpBPnRC3xwBsCkFU228lh_CWe5HUBMRPowajsrgs=",
"preAuthSessionId": "PyIwyA6VjdjNF5ggMV960rs3QXupRP2PEg2KcN5oi8s="
}'
curl --location --request POST '<YOUR_API_DOMAIN>/auth/<TENANT_ID>/signinup/code/consume' \
--header 'Content-Type: application/json; charset=utf-8' \
--data-raw '{
"linkCode": "s4hxpBPnRC3xwBsCkFU228lh_CWe5HUBMRPowajsrgs=",
"preAuthSessionId": "PyIwyA6VjdjNF5ggMV960rs3QXupRP2PEg2KcN5oi8s="
}'
Multi Tenancy
For a multi tenancy setup, the <TENANT_ID>
value can be fetched from tenantId
query parameter from the magic link. If it's not there in the link, you can use the value "public"
(which is the default tenant).
The response body from the API call has a status
property in it:
status: "OK"
: User sign in / up was successful. The response also contains more information about the user, for example their user ID, and if it was a new user or existing user.status: "INCORRECT_USER_INPUT_CODE_ERROR" | "EXPIRED_USER_INPUT_CODE_ERROR" | "RESTART_FLOW_ERROR"
: These responses indicate that the Magic link was invalid or expired.status: "GENERAL_ERROR"
: This is possible if you have overriden the backend API to send back a custom error message which should be displayed on the frontend.status: "SIGN_IN_UP_NOT_ALLOWED"
: This can happen during automatic account linking or during MFA. Thereason
prop that's in the response body contains a support code using which you can see why the sign in / up was not allowed.
note
On success, the backend will send back session tokens as part of the response headers which will be automatically handled by our frontend SDK for you.
#
If on a different device or browserIn this case, you want to show some UI that requires a user interaction before consuming the magic link. This is to protect against email clients opening the magic link on their servers and consuming the link. For example, you could show a button with text like - "Click here to login into this device".
On click, you can consume the magic link to log the user into that device. Follow the instructions in the above section to know which function / API to call.