ASP.NET Core Identity gives you registration, password hashing and email confirmation in a few lines of scaffolding. What it cannot tell you is that the person registering already holds four accounts made from the same laptop. Emails are free, so a confirmed email is not much of a barrier to multi-accounting.
This guide adds a device check to Identity registration with the Prynt .NET SDK. It works in the scaffolded Register Razor Page and in a minimal API endpoint, and it follows the same three steps as every Prynt signup recipe: verify the requestId, apply an account-per-device limit, link the account after it exists.
Install and register the client
dotnet add package Prynt.Server
The SDK targets .NET 8 and uses only the base class library. Register one client as a singleton with the secret key from configuration:
// Program.cs
using Prynt;
builder.Services.AddSingleton(new PryntServer(
builder.Configuration["Prynt:SecretKey"]!, // sk_live_… from user secrets or a vault
timeoutSeconds: 3));
Keep the sk_ key in user secrets locally and in your secret store in production. It never goes near the browser, which only ever sees your public pk_ key. A secret key only sees events from its own environment, so a test key in development cannot read live traffic.
Send the requestId with the form
If you have not scaffolded the Identity pages yet, run the Identity scaffolder for Account/Register. Then add a hidden field to the form and a property to the input model:
<!-- Areas/Identity/Pages/Account/Register.cshtml -->
<input type="hidden" asp-for="Input.PryntRequestId" id="prynt-request-id" />
<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
Prynt.load({ apiKey: 'pk_live_…' })
.then(p => p.identify({ tag: { action: 'signup' } }))
.then(r => { document.getElementById('prynt-request-id').value = r.requestId; })
.catch(() => { /* never block registration client-side */ });
</script>
public class InputModel
{
// …Email, Password, ConfirmPassword…
public string? PryntRequestId { get; set; }
}
Identifying on page load means the id is ready long before the user finishes typing a password. If a script blocker or a Global Privacy Control setting prevents identification, the field stays empty and the server decides what that means.
A small policy class
Keep the decision out of the page model so you can test it and reuse it from an API endpoint:
using System.Text.Json;
using Prynt;
public enum SignupAction { Allow, Flag, Block }
public sealed class SignupGuard(PryntServer prynt)
{
private const int MaxOtherAccounts = 2; // the third account from a device trips the limit
public async Task<(SignupAction Action, string Reason)> CheckAsync(string? requestId)
{
if (string.IsNullOrWhiteSpace(requestId))
return (SignupAction.Flag, "missing_request_id");
JsonElement ev;
try { ev = await prynt.GetEventAsync(requestId); }
catch (PryntException e) when (e.Status == 404) { return (SignupAction.Flag, "unknown_request_id"); }
catch (Exception e) when (e is PryntException or HttpRequestException or TaskCanceledException)
{
return (SignupAction.Allow, "prynt_unavailable"); // fail open: 5xx, network error or timeout
}
if (ev.TryGetProperty("linkedId", out var linked) && linked.ValueKind == JsonValueKind.String)
return (SignupAction.Block, "request_id_reused");
var count = ev.GetProperty("accountsOnDevice").GetProperty("count").GetInt32();
if (count >= MaxOtherAccounts)
return (SignupAction.Block, "device_account_limit");
return ev.GetProperty("decision").GetString() switch
{
"block" => (SignupAction.Block, "prynt_block"),
"challenge" => (SignupAction.Flag, "prynt_challenge"),
_ => (SignupAction.Allow, "clean"),
};
}
}
Register it with builder.Services.AddScoped<SignupGuard>();.
The SDK throws PryntException (with Status and Code) for HTTP errors from the API, but a network failure or a timeout surfaces as the usual HttpRequestException or TaskCanceledException, so the fail-open branch catches all three.
Two more details matter here. First, a requestId does not expire and is not single-use, so the guard checks whether the event is already attached to an account. If it is, someone is replaying an identification that already produced a user. Second, an empty requestId is flagged rather than blocked. Most of the time it is a privacy setting, not an attacker, and turning “no JavaScript” into “no account” is a choice you should make deliberately.
Wire it into the Register page
In Register.cshtml.cs, inject the guard and the client (and add using System.Security.Claims; for the review claim), run the check before CreateAsync, and link after it succeeds:
public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
{
if (!ModelState.IsValid) return Page();
var (action, reason) = await _signupGuard.CheckAsync(Input.PryntRequestId);
if (action == SignupAction.Block)
{
_logger.LogInformation("Registration refused: {Reason}", reason);
ModelState.AddModelError(string.Empty,
"We couldn't create an account from this device. If you think this is a mistake, contact support.");
return Page();
}
var user = CreateUser();
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await _userManager.CreateAsync(user, Input.Password);
if (result.Succeeded)
{
if (action == SignupAction.Flag)
await _userManager.AddClaimAsync(user, new Claim("prynt_review", reason));
if (!string.IsNullOrWhiteSpace(Input.PryntRequestId))
{
try { await _prynt.UpdateEventAsync(Input.PryntRequestId, user.Id); }
catch (Exception e) when (e is PryntException or HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(e, "Prynt link failed");
}
}
// …existing email confirmation and sign-in code…
}
// …existing error handling…
}
The order is the point. The check runs before any row is written, so a refused registration leaves nothing to clean up. The link runs only after result.Succeeded, so a password that fails validation never counts against the device. And the link call is wrapped so a Prynt hiccup cannot turn a successful registration into an error page.
Storing the flag as a claim is one option; a column on your user entity works as well. Either way, record the reason, because “why was this account held for review?” is a question support will eventually ask.
The same check in a minimal API
If your frontend is a SPA posting to an API, the guard drops into a minimal API endpoint unchanged:
app.MapPost("/api/register", async (RegisterRequest req, SignupGuard guard,
UserManager<IdentityUser> users, PryntServer prynt) =>
{
var (action, reason) = await guard.CheckAsync(req.PryntRequestId);
if (action == SignupAction.Block)
return Results.Problem("We couldn't create an account from this device.", statusCode: 403);
var user = new IdentityUser { UserName = req.Email, Email = req.Email };
var result = await users.CreateAsync(user, req.Password);
if (!result.Succeeded) return Results.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
if (!string.IsNullOrWhiteSpace(req.PryntRequestId))
{
try { await prynt.UpdateEventAsync(req.PryntRequestId, user.Id); }
catch (Exception e) when (e is PryntException or HttpRequestException or TaskCanceledException)
{ /* log it; never fail a registration that already succeeded */ }
}
return Results.Ok(new { user.Id, review = action == SignupAction.Flag });
});
record RegisterRequest(string Email, string Password, string? PryntRequestId);
External logins and other entry points
Identity’s scaffolding has more than one way to create a user, and a device check on the Register page covers only one of them.
- External logins. Users who sign up with Google or Microsoft go through
ExternalLogin.cshtml.cs, whoseOnPostConfirmationAsynccallsCreateAsyncon its own. Add the same hidden field and the sameCheckAsynccall there, or abusers simply switch to the social button. - Invitations and admin-created users. Accounts created by your staff or through an invite flow usually do not need the check, but they should still be linked if the invitee completes signup in a browser you identified.
- API registrations. If mobile clients register through an API, they need a
requestIdfrom the native SDKs (iOS, Android, Flutter or React Native) rather than the browser agent. The server code does not change.
It also helps to keep the verdict next to the user. A small table with the user id, the visitorId, the reason and the timestamp gives support an answer when someone asks why their account is under review, and gives you the data to decide whether the limit is set right. Keep that record under the same retention rules as the rest of your account data.
Choosing the limit
A limit of two other accounts is a reasonable start: it tolerates a work and a personal account on one machine and refuses the third. If your free tier is generous in compute or credits, go stricter. If households commonly share a device, go looser, or replace Block with a verification step such as a confirmed phone number before the trial unlocks.
Whatever you pick, run the guard in flag-only mode first and look at a week of results. Our guides on device-based signup limits and one account per person cover how to read that data, and the signup fraud overview puts device limits next to email and payment controls. The full event schema, including every Smart Signal, is in the docs.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.