Anonymous Users

Anonymous users allow you to treat visitors as first-class identities before they provide a verified email, phone number, or username.

You can reduce registration friction while still using Descope as your customer identity layer: issue session tokens, attach custom data, and later convert the same anonymous user to a standard user while retaining information you collected during the anonymous phase.

How Anonymous Users Work

Descope represents an anonymous user by issuing a dedicated anonymous session JWT (not a standard user record with login IDs). That token:

  • Is signed by Descope and behaves like other session tokens for your app (e.g. API authorization).
  • Has a lifetime tied to the JWT (and refresh behavior you configure)—when the session expires, that anonymous identity ends unless you refresh or convert the user.
  • Carries a danu claim (true) in the payload so your backend can tell this is an anonymous session.
  • Can include custom claims (for example user preferences, unverified email addresses, or app-specific flags) for use in your product.

When you are ready, you can convert the anonymous user to a regular user, without losing any data you've already gathered on the user.

Creating Anonymous Users

With Flows

The Create Anonymous user - Add Information To JWT flow template provides a starting point. Upon completion, Descope issues an anonymous identity token.

Create Anonymous User Flow

The following example illustrates a typical JWT payload and header after the flow runs. The danu claim marks the session as anonymous; displayName (or any claims you configure) represents optional custom data for your application:

// Payload:
{
  "danu": true,
  "displayName": "xxxxx",
  "drn": "DS",
  "exp": 1731843388,
  "iat": 1731842788,
  "iss": "xxxxxxxxx",
  "rexp": "2024-12-15T11:26:28Z",
  "sub": "xxxxxxxxx"
}

// Header:
{
  "alg": "RS256",
  "kid": "xxxxxxxxxxxxxxxx",
  "typ": "JWT"
}

With SDKs

You can use the Descope Management SDK to mint an anonymous session JWT programmatically. A management key is required. For broader SDK setup, see User management SDKs.

Backend SDK

Install SDK

Terminal
npm i --save @descope/node-sdk
Terminal
pip3 install descope
Terminal
go get github.com/descope/go-sdk
// Include the following in your `pom.xml` (Maven)
<dependency>
    <artifactId>java-sdk</artifactId>
    <groupId>com.descope</groupId>
    <version>sdk-version</version> // Check https://github.com/descope/descope-java/releases for the latest versions
</dependency>
Terminal
gem install descope
Terminal
composer require descope/descope-php
Terminal
dotnet add package Descope

Import and initialize SDK

import DescopeClient from '@descope/node-sdk';
try{
    //  baseUrl="<URL>" // When initializing the Descope client, you can also configure the baseUrl ex: https://auth.company.com  - this is useful when you utilize a custom domain within your Descope project.
    const descopeClient = DescopeClient({ projectId: '__ProjectID__' });
} catch (error) {
    // handle the error
    console.log("failed to initialize: " + error)
}
from descope import (
    REFRESH_SESSION_TOKEN_NAME,
    SESSION_TOKEN_NAME,
    AuthException,
    DeliveryMethod,
    DescopeClient,
    AssociatedTenant,
    RoleMapping,
    AttributeMapping,
    LoginOptions
)
try:
    # You can configure the baseURL by setting the env variable Ex: export DESCOPE_BASE_URI="https://auth.company.com  - this is useful when you utilize custom domain within your Descope project."
    descope_client = DescopeClient(project_id='__ProjectID__')
except Exception as error:
    # handle the error
    print ("failed to initialize. Error:")
    print (error)
import "github.com/descope/go-sdk/descope"
import "github.com/descope/go-sdk/descope/client"

// Utilizing the context package allows for the transmission of context capabilities like cancellation
//      signals during the function call. In cases where context is absent, the context.Background()
//      function serves as a viable alternative.
//      Utilizing context within the Descope GO SDK is supported within versions 1.6.0 and higher.
import (
	"context"
)

// DescopeBaseURL // within the client.Config, you can also configure the baseUrl ex: https://auth.company.com  - this is useful when you utilize a custom domain within your Descope project.

descopeClient, err := client.NewWithConfig(&client.Config{ProjectID:"__ProjectID__"})
if err != nil {
    // handle the error
    log.Println("failed to initialize: " + err.Error())
}
import com.descope.client.Config;
import com.descope.client.DescopeClient;

var descopeClient = new DescopeClient(Config.builder().projectId("__ProjectID__").build());
require 'descope'

@project_id = ENV['__ProjectID__']
@client = Descope::Client.new({ project_id: @project_id})
require 'vendor/autoload.php';
use Descope\SDK\DescopeSDK;
 
$descopeSDK = new DescopeSDK([
    'projectId' => $_ENV['__ProjectID__'],
]);
using Descope;

// Configure the Descope client
var options = new DescopeClientOptions
{
    ProjectId = "__ProjectID__",                // Required
    ManagementKey = "DESCOPE_MANAGEMENT_KEY",   // Optional, for management APIs
    BaseUrl = "__BaseURL__",        // Optional, auto-detected from project ID, only set to override 
    JwksCacheDuration = TimeSpan.FromMinutes(5) // Optional, how long public signing keys are cached (default: 5 minutes)
};

// Option 1: Dependency Injection - best for ASP.NET Core apps; registers IDescopeClient so it can be injected into your services
builder.Services.AddDescopeClient(options);

// Then inject IDescopeClient wherever you need it
public class MyService
{
    private readonly IDescopeClient descopeClient;
    
    public MyService(IDescopeClient client)
    {
        descopeClient = client;
    }
}

// Option 2: Factory (Create once and reuse this instance) - best for console apps, background workers, or when you need to manually control the client's lifetime
var descopeClient = DescopeManagementClientFactory.Create(options);

Create an Anonymous User

This operation creates an anonymous user within the project with the details provided.

Note

You can also perform this operation through the Anonymous User Management API.

 // Args:
//  customClaims (Record<string, any>, optional): A dictionary of custom claims to include in the JWT.
//     These claims can be used to store additional user information.
//  selectedTenant (string, optional): The ID of the tenant to associate with the JWT.
//     This is useful for multi-tenant applications.
//  refreshDuration (number, optional): Duration in seconds for which the new JWT will be valid.

const customClaims = {
    role: "guest",
    permissions: ["read"]
};

const selectedTenant = "tenant_123";
const refreshDuration = 3600;

const resp = await descopeClient.management.jwt.anonymous(customClaims, selectedTenant, refreshDuration);
if (!resp.ok) {
    console.log("Failed to generate JWT for anonymous user.");
    console.log("Status Code: " + resp.code);
    console.log("Error Code: " + resp.error.errorCode);
    console.log("Error Description: " + resp.error.errorDescription);
    console.log("Error Message: " + resp.error.errorMessage);
} else {
    console.log("Successfully generated JWT for anonymous user.");
    console.log(resp.data);
}
# Args:
#  custom_claims (dict, optional): A dictionary of custom claims to include in the JWT.
#     These claims can be used to store additional user information.
#  tenant_id (str, optional): The ID of the tenant to associate with the JWT.
#     This is useful for multi-tenant applications.
#  refresh_duration (int, optional): Duration in seconds for which the new JWT will be valid.

custom_claims = {
    "role": "guest",
    "permissions": ["read"]
}
tenant_id = "tenant_123"
refresh_duration = 3600

try:
    jwt_response = descope_client.mgmt.jwt.anonymous(
        custom_claims=custom_claims,
        tenant_id=tenant_id,
        refresh_duration=refresh_duration,
    )
    print("Successfully generated JWT for anonymous user")
    print(json.dumps(jwt_response, indent=4))
except AuthException as error:
    print("Failed to generate JWT")
    print("Status Code: " + str(error.status_code))
    print("Error: " + str(error.error_message))
// Args:
//  ctx (context.Context): Application context for the transmission of context capabilities like
//     cancellation signals during the function call. If no context is available,
//     use context.Background() as an alternative.
//  customClaims (map[string]any, optional): A map of custom claims to include in the JWT. These claims
//     can be used to store additional user information.
//  selectedTenant (string, optional): The ID of the tenant to associate with the JWT. This is useful
//     for multi-tenant applications.
//  refreshDuration (int32, optional): Duration in seconds for which the new JWT will be valid.

ctx := context.Background()
customClaims := map[string]any{
	"role": "guest",
	"permissions": []string{"read"},
}
selectedTenant := "tenant_123"
refreshDuration := int32(3600)

res, err := descopeClient.Management.JWT().Anonymous(ctx, customClaims, selectedTenant, refreshDuration)
if err != nil {
	fmt.Println("Failed to generate JWT for anonymous user:", err)
} else {
	fmt.Println("Successfully generated JWT for anonymous user:")
	fmt.Printf("Session Token: %s\n", res.SessionToken)
	fmt.Printf("Refresh Token: %s\n", res.RefreshToken)
}
// Args:
//   customClaims (Map<String, Object>): Optional, custom claims to include in the JWT.
//   selectedTenant (String): Optional, the tenant ID to associate with the JWT.
//   refreshDuration (int): Optional, duration in seconds for which the new JWT will be valid.
JwtService jwts = descopeClient.getManagementServices().getJwtService();
try {
  AnonymousUserRequest request = AnonymousUserRequest.builder()
      .customClaims(new HashMap<String, Object>() {{
        put("role", "guest");
      }})
      .selectedTenant("tenant_123")
      .refreshDuration(3600)
      .build();
  AuthenticationInfo authInfo = jwts.anonymous(request);
  System.out.println("Successfully generated JWT for anonymous user.");
  System.out.println(authInfo.getToken().getJwt());
  System.out.println(authInfo.getRefreshToken().getJwt());
} catch (DescopeException de) {
  // Handle the error
}
// Args:
//   CustomClaims (AnonymousUserRequest_customClaims): Optional, custom claims to include in the JWT.
//   SelectedTenant (string): Optional, the tenant ID to associate with the JWT.
//   RefreshDuration (int?): Optional, duration in seconds for which the new JWT will be valid.
var customClaims = new AnonymousUserRequest_customClaims();
customClaims.AdditionalData["role"] = "guest";

var anonymousRequest = new AnonymousUserRequest
{
    CustomClaims = customClaims,
    SelectedTenant = "tenant_123",
    RefreshDuration = 3600,
};

var anonymousResponse = await descopeClient.Mgmt.V1.Auth.Anonymous.PostAsync(anonymousRequest);
Console.WriteLine("Successfully generated JWT for anonymous user.");
Console.WriteLine(anonymousResponse?.SessionJwt);
Console.WriteLine(anonymousResponse?.RefreshJwt);

Converting Anonymous Users to Regular Users

With Flows

To move from an anonymous session to a regular user, use a flow such as Anonymous User Conversion. This flow authenticates the user and links a verified login ID while preserving context from the anonymous phase.

Update anonymous users magic link flow

  • The template demonstrates one authentication pattern; you can adapt the same approach to other factors your product supports.
  • Verify ownership of the login ID (for example via magic link or OTP) before completing conversion. Doing so prevents users from attaching an email or phone number that already belongs to another account.

With SDKs

Note

You may not have access to the refresh token of the anonymous user from your backend, depending on your token management settings.

If this is the case, you will need to use Flows instead, to convert the anonymous user.

You can convert an anonymous user in your backend by calling any update-user auth method while sending the anonymous user's refresh JWT.

Descope detects that there is no real user row for that subject and that the JWT is anonymous (danu: true), then creates a regular user that reuses the anonymous user's ID. Anything you keyed off that ID stays attached.

After you verify the OTP (or magic link / phone update), you receive a real session. On a successful conversion, the auth response includes firstSeen: true.

Convert with OTP Update Email

Start conversion by updating the anonymous user's email via OTP, then verify the code to complete conversion and issue a regular session.

Note

You must verify ownership of the login ID (for example via an OTP or magic link) before completing conversion.

This is to prevent users from attaching an email or phone number, to the newly created user, that already belongs to another account.

// Args:
//  loginId (string): The new email — becomes the login ID for the converted user.
//  email (string): The email address to verify and attach.
//  refreshToken (string): The anonymous user's refresh JWT.
//  updateOptions (object): Use addToLoginIDs and onMergeUseExisting when attaching the new login ID.
//  code (string): The OTP code the user received by email.

const loginId = "user@example.com";
const email = "user@example.com";
const refreshToken = "xxxxx"; // anonymous refresh JWT
const updateOptions = {
    addToLoginIDs: true,
    onMergeUseExisting: true,
};

const updateResp = await descopeClient.otp.update.email(loginId, email, refreshToken, updateOptions);
if (!updateResp.ok) {
    console.log("Failed to start anonymous user conversion.");
    console.log("Status Code: " + updateResp.code);
    console.log("Error Code: " + updateResp.error.errorCode);
    console.log("Error Description: " + updateResp.error.errorDescription);
    console.log("Error Message: " + updateResp.error.errorMessage);
} else {
    console.log("Successfully started OTP email update.");
    console.log(updateResp.data);
}

// After the user enters the OTP code:
const code = "xxxxxx";
const verifyResp = await descopeClient.otp.verify.email(loginId, code);
if (!verifyResp.ok) {
    console.log("Failed to verify OTP code.");
    console.log("Status Code: " + verifyResp.code);
    console.log("Error Code: " + verifyResp.error.errorCode);
    console.log("Error Description: " + verifyResp.error.errorDescription);
    console.log("Error Message: " + verifyResp.error.errorMessage);
} else {
    console.log("Successfully converted anonymous user.");
    console.log("First seen: " + verifyResp.data.firstSeen);
    console.log(verifyResp.data);
}
# Args:
#  login_id (str): The new email — becomes the login ID for the converted user.
#  email (str): The email address to verify and attach.
#  refresh_token (str): The anonymous user's refresh JWT.
#  add_to_login_ids (bool): Append the email as a login ID.
#  on_merge_use_existing (bool): On login ID conflict, keep the existing user.
#  code (str): The OTP code the user received by email.

login_id = "user@example.com"
email = "user@example.com"
refresh_token = "xxxxx"  # anonymous refresh JWT

try:
    masked = descope_client.otp.update_user_email(
        login_id=login_id,
        email=email,
        refresh_token=refresh_token,
        add_to_login_ids=True,
        on_merge_use_existing=True,
    )
    print("Successfully started OTP email update")
    print(masked)
except AuthException as error:
    print("Failed to start anonymous user conversion")
    print("Status Code: " + str(error.status_code))
    print("Error: " + str(error.error_message))

# After the user enters the OTP code:
code = "xxxxxx"
try:
    jwt_response = descope_client.otp.verify_code(
        method=DeliveryMethod.EMAIL,
        login_id=login_id,
        code=code,
    )
    print("Successfully converted anonymous user")
    print("First seen: " + str(jwt_response.get("firstSeen")))
    print(json.dumps(jwt_response, indent=4))
except AuthException as error:
    print("Failed to verify OTP code")
    print("Status Code: " + str(error.status_code))
    print("Error: " + str(error.error_message))
// Args:
//  ctx (context.Context): Application context. Use context.Background() if none is available.
//  loginID (string): The new email — becomes the login ID for the converted user.
//  email (string): The email address to verify and attach.
//  updateOptions (*descope.UpdateOptions): Use AddToLoginIDs and OnMergeUseExisting when attaching the new login ID.
//  request (*http.Request): Must carry the anonymous refresh JWT (for example, the DSR cookie).
//  code (string): The OTP code the user received by email.
//  w (http.ResponseWriter): Optional; may be used to set session cookies on the response.

ctx := context.Background()
loginID := "user@example.com"
email := "user@example.com"
refreshJWT := "xxxxx" // anonymous refresh JWT
updateOptions := &descope.UpdateOptions{
	AddToLoginIDs:      true,
	OnMergeUseExisting: true,
}

// r and w come from your HTTP handler. Attach the anonymous refresh JWT (DSR cookie).
r.AddCookie(&http.Cookie{Name: descope.RefreshCookieName, Value: refreshJWT})

masked, err := descopeClient.Auth.OTP().UpdateUserEmail(ctx, loginID, email, updateOptions, r)
if err != nil {
	fmt.Println("Failed to start anonymous user conversion:", err)
} else {
	fmt.Println("Successfully started OTP email update:", masked)
}

// After the user enters the OTP code:
code := "xxxxxx"
authInfo, err := descopeClient.Auth.OTP().VerifyCode(ctx, descope.MethodEmail, loginID, code, w)
if err != nil {
	fmt.Println("Failed to verify OTP code:", err)
} else {
	fmt.Println("Successfully converted anonymous user")
	fmt.Printf("First seen: %v\n", authInfo.FirstSeen)
}
// Args:
//  loginId (String): The new email — becomes the login ID for the converted user.
//  email (String): The email address to verify and attach.
//  refreshToken (String): The anonymous user's refresh JWT.
//  updateOptions (UpdateOptions): Use addToLoginIds and onMergeUseExisting when attaching the new login ID.
//  code (String): The OTP code the user received by email.

String loginId = "user@example.com";
String email = "user@example.com";
String refreshToken = "xxxxx"; // anonymous refresh JWT
UpdateOptions updateOptions = UpdateOptions.builder()
    .addToLoginIds(true)
    .onMergeUseExisting(true)
    .build();

OTPService otps = descopeClient.getAuthenticationServices().getOtpService();
try {
  String masked = otps.updateUserEmail(loginId, email, refreshToken, updateOptions);
  System.out.println("Successfully started OTP email update.");
  System.out.println(masked);

  // After the user enters the OTP code:
  String code = "xxxxxx";
  AuthenticationInfo authInfo = otps.verifyCode(DeliveryMethod.EMAIL, loginId, code);
  System.out.println("Successfully converted anonymous user.");
  System.out.println("First seen: " + authInfo.getFirstSeen());
} catch (DescopeException de) {
  // Handle the error
}
// Args:
//  LoginId (string): The new email — becomes the login ID for the converted user.
//  Email (string): The email address to verify and attach.
//  refreshJwt (string): The anonymous user's refresh JWT (required by PostWithJwtAsync).
//  AddToLoginIDs / OnMergeUseExisting: Attach the email as a login ID and keep the existing user on merge.
//  Code (string): The OTP code the user received by email.

var loginId = "user@example.com";
var email = "user@example.com";
var refreshJwt = "xxxxx"; // anonymous refresh JWT

try
{
    var updateResponse = await descopeClient.Auth.V1.Otp.Update.Email.PostWithJwtAsync(
        new UpdateUserEmailOTPRequest
        {
            LoginId = loginId,
            Email = email,
            AddToLoginIDs = true,
            OnMergeUseExisting = true,
        },
        refreshJwt);
    Console.WriteLine("Successfully started OTP email update.");
    Console.WriteLine(updateResponse?.MaskedEmail);

    // After the user enters the OTP code:
    var code = "xxxxxx";
    var authResponse = await descopeClient.Auth.V1.Otp.Verify.Email.PostAsync(
        new OTPVerifyCodeRequest
        {
            LoginId = loginId,
            Code = code,
        });
    Console.WriteLine("Successfully converted anonymous user.");
    Console.WriteLine("First seen: " + authResponse?.FirstSeen);
}
catch (DescopeException ex)
{
    // Handle the error
}
Was this helpful?

On this page