Self-hostable authentication for .NET
Klassd.Auth is a code-first, NuGet-distributed authentication core for .NET — email/password, passwordless codes, passkeys, rotating sessions, social login & SSO, account linking, MFA, email verification and a per-user metadata store. The HTTP API ships with the library: one MapKlassdAuth() call mounts the core and the schema is created at startup.
What you get
Email & password
Sign-up / sign-in with PBKDF2-HMAC-SHA256 hashing and per-password salts (swap for Argon2id if preferred). The endpoints ship with the library — you never hand-write them.
Passwordless codes
One-time codes over email or SMS — fixed-time compare, TTL and attempt lockout, no account enumeration. Bring your own sender, or drop in the Twilio package for SMS.
Passkeys (WebAuthn)
Phishing-resistant FIDO2 sign-in built on Fido2NetLib, including usernameless/discoverable login. The ceremony challenge rides a stateless, DataProtection-protected cookie — multi-node safe.
Sessions done right
A short-lived, stateless access JWT plus an opaque, rotating refresh token. Reuse of a rotated refresh token is detected and revokes the session defensively.
Social login & SSO
OIDC (Entra ID, Google) and OAuth 2.0 (GitHub, Facebook, Instagram, TikTok) external login, with a clean seam for adding your own provider.
Account linking
One identity, many login methods. Add a password or link a social account to an existing user; unlink is guarded so the last method can never be removed. Opt-in auto-link only on a verified email.
MFA (TOTP)
Enroll a TOTP authenticator: the core generates a secret and an otpauth:// URI for the QR code, then verifies six-digit codes at sign-in.
Email verification
Send a verification link and consume single-use tokens. Tokens are hashed, TTL-bound and persisted by the storage adapter, so they survive restarts and scale across nodes.
Per-user typed metadata
Store app-specific data as one JSON document accessed through typed sections, so two apps never collide. Roles ride the same mechanism via RolesService.
Pluggable storage
The core depends only on IUserStore / ISessionStore / IUserMetadataStore. Bind SQLite, PostgreSQL or MongoDB with a Data.* adapter — raw drivers, no EF/ORM.
Multi-tenancy
Serve many tenants from one database: every identity lookup is scoped to the tenant and the access token carries it as a tnt claim, so the same email can exist independently per tenant. Defaults to a single "public" tenant — single-tenant apps change nothing.
Drop-in cookie sign-in
For Blazor / server-rendered apps, add cookie delivery and the external-SSO seam with one call. Optional loopback bypass means no login on localhost / port-forward.
RS256 + JWKS by default
With a database adapter, signing defaults to rotating RS256 — keys persisted and auto-rotated — publishing JWKS and an OpenID discovery doc so resource servers validate via discovery. HS256 shared-secret is an opt-out.
Admin dashboard
A drop-in Blazor (Interactive Server) UI to maintain users — list/search, create, enable/disable, set password, edit roles, manage linked methods, and delete or anonymize (GDPR erasure). It can also run an import (file or live DB) as a background job with live progress.
Automation webhooks
Inbound HMAC-signed webhooks let a customer-service tool disable, delete or anonymize a user — so a support ticket can be automated end-to-end, with replay protection and an audit log.
Custom claims & sessions
Add claims to every access token with an enricher (fresh on each refresh), or merge into a live session — the SuperTokens MergeIntoAccessTokenPayload equivalent, resolvable from the request. Arrays land as real JSON claims.
Override anything (hooks)
Every core service is an interface with a delegating decorator — wrap it, change one method, call base for the rest. The same model as SuperTokens recipe-function overrides, plus session-create and third-party post-sign-in hooks that hand you the session.
Migrate in from Auth0 / SuperTokens
Import an existing user base — bcrypt/argon2 passwords verify at login (no forced reset), with social links, roles, metadata and TOTP. From a JSON export or by reading a SuperTokens core DB (Postgres/MySQL) directly. Idempotent, dry-runnable, and can fold several databases into one multi-tenant instance.
Quickstart
Install the core plus a storage adapter and the HTTP delivery package. While Klassd.Auth is in beta the packages are prerelease:
dotnet add package Klassd.Auth.Core --prerelease
dotnet add package Klassd.Auth.Data.Sqlite --prerelease # storage adapter (or .Data.Postgres / .Data.MongoDb)
dotnet add package Klassd.Auth.AspNetCore --prerelease # JSON/JWT HTTP API (MapKlassdAuth)
dotnet add package Klassd.Auth.AspNetCore.Cookies --prerelease # cookie sign-in for Blazor / server-rendered apps
dotnet add package Klassd.Auth.Passwordless --prerelease # one-time codes (email + SMS)
dotnet add package Klassd.Auth.Passkeys --prerelease # passkeys (WebAuthn / FIDO2)
dotnet add package Klassd.Auth.Sms.Twilio --prerelease # optional: Twilio SMS sender
dotnet add package Klassd.Auth.OpenIdConnect --prerelease # OIDC external login + Entra ID + Google
dotnet add package Klassd.Auth.OAuth --prerelease # OAuth 2.0 — GitHub, Facebook, Instagram, TikTok
dotnet add package Klassd.Auth.Dashboard --prerelease # Blazor user-admin dashboard
dotnet add package Klassd.Auth.Webhooks --prerelease # inbound HMAC webhooks (disable/delete/anonymize)Wire it up in Program.cs
AddKlassdAuth(...) returns a builder you use to pick a storage adapter; MapKlassdAuth() mounts the full HTTP API and creates the schema at startup:
builder.Services
.AddKlassdAuth(new SessionConfig { SigningKey = "<32+ byte secret>" })
.UseSqlite("Data Source=klassd-auth.db"); // or .UsePostgres(...) / .UseMongoDb(...)
var app = builder.Build();
app.MapKlassdAuth(); // mounts the full HTTP API; schema is created automatically at startup
app.Run(); That's the whole host. The endpoints are shipped by the library — you don't hand-write them. The Klassd.Auth.Sample project is a complete, runnable example.
Email/password & sessions
Sign-up and sign-in return a short-lived access JWT (stateless) plus an opaque, rotating refresh token. Passwords are hashed with PBKDF2-HMAC-SHA256 and a per-password salt.
// POST /auth/signup and /auth/signin return session tokens:
// { accessToken, refreshToken, expiresAt }
// The access token is a short-lived JWT; the refresh token is opaque and rotating.
// POST /auth/refresh rotates the refresh token and issues a new access token.
// Reusing an already-rotated refresh token is detected and revokes the session.
// POST /auth/logout revokes a session.
// From code, the same logic is available through UserAccountService:
var user = await accounts.CreateLocalAsync(username: null, email: "a@x.com", password);
if (accounts.VerifyPassword(user, password) && !user.Disabled) { /* issue tokens */ }
await accounts.SetDisabledAsync(user.Id, true); // soft-delete- Rotation —
POST /auth/refreshissues a new access token and rotates the refresh token; the previous one is invalidated. - Reuse detection — replaying a rotated refresh token revokes the session defensively (a sign of token theft).
- From code — the same logic is available through
UserAccountService, the union of what the CMS and Workflows user services expose, so it can back an app's existing Blazor cookie sign-in.
Passwordless (email & SMS one-time codes)
Add passwordless sign-in and map its endpoints. Codes are stored hashed with a TTL and an attempt counter, compared in fixed time, and delivered through the registered sender — email out of the box, or SMS via the Twilio package (otherwise codes log to the console):
auth.AddPasswordless(); // 6-digit codes, 10-min TTL, 5 attempts (defaults)
auth.AddTwilioSms(sid, token, fromNumber); // optional: real SMS (otherwise codes log to the console)
app.MapKlassdPasswordless(); // JSON: POST /auth/passwordless/{start,verify}
// POST /auth/passwordless/start { "identifier": "a@b.com", "channel": "Email" } -> 202 (always)
// POST /auth/passwordless/verify { "identifier": "a@b.com", "channel": "Email", "code": "123456" }
// -> session tokens; resolves or auto-provisions the user by email/phone.
// MapKlassdPasswordlessCookie() signs the user into the app cookie instead.- No enumeration —
startalways returns202, never revealing whether the identifier maps to an account. - Throttled — codes expire (default 10 min) and lock out after a configurable number of failed attempts.
- Resolve or provision — a successful
verifyfinds the user by email/phone, or creates one. The cookie variant signs the user in instead of returning tokens.
Passkeys (WebAuthn / FIDO2)
Phishing-resistant passkeys built on Fido2NetLib, with usernameless / discoverable login. Registration runs against the authenticated user; the four ceremony endpoints handle the browser navigator.credentials round-trips:
auth.AddPasskeys(o =>
{
o.ServerDomain = "example.com"; // the WebAuthn relying-party id (use "localhost" in dev)
o.Origins = ["https://example.com"];
});
app.MapKlassdPasskeys(); // POST /passkeys/{register,login}/{options,verify}
// register/* requires an authenticated user; login supports usernameless / discoverable credentials.
// The ceremony challenge rides a stateless, DataProtection-protected cookie (multi-node safe).
// register/verify -> session tokens; MapKlassdPasskeysCookie() issues the app cookie instead.- Stateless challenge — the per-ceremony challenge is held in a DataProtection-protected cookie, so it works across nodes with no shared cache (an in-memory store is available for single-node).
- Clone detection — the signature counter is persisted and validated on each assertion.
- Credentials — stored in a
passkey_credentialstable per storage adapter; a user can register several.
Social login & SSO (OIDC & OAuth 2.0)
Add external providers on the same builder. On an unauthenticated sign-in the identity is auto-provisioned (or, opt-in, merged into a matching account) through UserAccountService.ProvisionExternalAsync(...); a signed-in user links one explicitly (see Account linking):
// Microsoft Entra ID (Azure AD). tenantId can be a directory id, or "organizations"/"common".
auth.AddEntraId(
tenantId: builder.Configuration["Auth:Entra:TenantId"]!,
clientId: builder.Configuration["Auth:Entra:ClientId"]!,
clientSecret: builder.Configuration["Auth:Entra:ClientSecret"]!);
auth.AddGoogle(clientId, clientSecret); // OIDC
auth.AddGitHub(clientId, clientSecret); // OAuth 2.0 (non-OIDC)
auth.AddFacebook(clientId, clientSecret); // OAuth 2.0
auth.AddInstagram(clientId, clientSecret); // OAuth 2.0 — no email returned
auth.AddTikTok(clientKey, clientSecret); // OAuth 2.0 — no email returned
auth.AddOpenIdConnect("Company SSO", config.GetSection("Oidc")); // any other OIDC provider- Entra ID — OIDC: stable id from the
oidclaim, name frompreferred_username.tenantIdcan be a directory id ororganizations/common. - Google — OIDC via
AddGoogle(...)(Klassd.Auth.OpenIdConnect). - GitHub / Facebook / Instagram / TikTok — OAuth 2.0 via
AddGitHub/AddFacebook/AddInstagram/AddTikTok(...)(Klassd.Auth.OAuth). Instagram and TikTok return no email, so they only ever explicit-link or provision a fresh account. - Anything else —
AddOpenIdConnect(name, configSection)for any standards-compliant OIDC provider (Okta, Auth0, …).
Account linking
An account is one identity with many LoginMethods. A signed-in user can attach another provider, add a password, or remove a method — all keyed to their own session:
// Cookie endpoints — the signed-in user links to THEIR account:
// GET /auth/link/{scheme} -> challenge a provider, attach it on the callback
// POST /auth/link/password -> a social-/passwordless-only user gains a password
// POST /auth/unlink -> remove a method (the last one is guarded)
// GET /auth/me/methods -> list the caller's own login methods
// Or from code, via UserAccountService:
await accounts.LinkExternalAsync(userId, "facebook", info); // never steals an identity owned elsewhere
await accounts.AddPasswordAsync(userId, password); // false if a password already exists
await accounts.UnlinkAsync(userId, methodId); // false if it is the last method
// Opt-in: auto-merge an unauthenticated social sign-in into an existing account, but ONLY on a
// provider-VERIFIED matching email (off by default — unverified-email auto-link is a takeover vector):
auth.AddKlassdAuthCookies(o => o.AutoLinkByVerifiedEmail = true);- Explicit & safe — linking is tied to the authenticated session; an identity already owned by another user is never stolen (it returns a conflict).
- Add a password — a social- or passwordless-only account can gain a password; passwordless stays available on any account carrying that email/phone.
- Last-method guard — unlinking refuses to remove a user's final login method, so an account can't lock itself out.
- Opt-in auto-link — an unauthenticated social sign-in merges into an existing account only on a provider-verified matching email (
AutoLinkByVerifiedEmail, off by default).
Collecting an email from no-email providers
Instagram and TikTok never share an email (Facebook may be denied), so those accounts are provisioned with a null primary email — it's nullable by design. Collect and verify one after sign-in, then treat the account as complete:
// Instagram & TikTok never share an email (Facebook may be denied), so those accounts are
// provisioned with PrimaryEmail = null. Collect + verify one after sign-in:
// POST /auth/me/email { email } -> sends a verification link (409 if already in use)
// GET /auth/me/email/confirm?token=… -> sets it as the user's VERIFIED primary email
// From code:
if (user.PrimaryEmail is null) { /* route to your "add your email" page */ }
await accounts.SetPrimaryEmailAsync(userId, email, verified: true); // guards against an email owned elsewherestart rejects an address already owned by another user; the confirm link's token is the proof of ownership, so the address becomes the user's verified primary email (and a usable passwordless identity). Apps that require an email can gate on PrimaryEmail is null and route to the collection page.
MFA (TOTP)
Time-based one-time passwords: the core generates a secret and an otpauth:// URI you render as a QR code, then verifies six-digit codes.
// POST /auth/mfa/enroll → generates a TOTP secret + an otpauth:// URI (render as a QR code)
// { secret, otpauthUri }
// POST /auth/mfa/verify → verifies a six-digit TOTP code
// { userId, code } → { verified: true }Email verification
Send a verification link and consume single-use tokens:
// POST /auth/email/send-verification → sends a verification link with a single-use token
// GET /auth/email/verify?token=… → consumes the token, marks the email verified
//
// Tokens are hashed, TTL-bound and persisted by the storage adapter, so they survive
// restarts and work across multiple nodes.Verification tokens are hashed, TTL-bound and single-use, persisted by the storage adapter — so they survive restarts and scale across nodes.
Password reset (forgot password)
Self-service reset by emailed single-use link. forgot always returns 202 (no account enumeration); reset sets the new password and revokes the user's existing sessions:
// Self-service "forgot password" — emailed single-use link, no account enumeration:
// POST /auth/password/forgot { "identifier": "a@b.com" } -> 202 (always)
// POST /auth/password/reset { "token": "…", "newPassword": "…" } -> 204 / 400
//
// reset consumes the token, sets the password (adding an email/password method if the account had
// none) and REVOKES the user's existing sessions. Configure the link's base URL:
app.MapKlassdAuth(o => o.PasswordResetUrlBase = "https://app.example/reset-password");Admin dashboard (Blazor)
A drop-in Blazor (Interactive Server) UI to maintain users — list/search, create, enable/disable, set password, edit roles, manage linked login methods, and delete or anonymize behind a typed confirm. It talks in-process to the auth services (no extra API):
auth.AddKlassdAuthDashboard(); // after AddKlassdAuth(...) + a storage adapter
// Configurable path (default "/auth/dashboard"); ALWAYS requires login (anonymous -> cookie login path).
app.MapKlassdAuthDashboard(authorizationPolicy: "Admin");
app.MapKlassdAuthDashboard("/admin/users", "Admin"); // …or mount it anywhere
// A self-contained branch — it owns routing/auth/antiforgery/static assets. The host only sets
// <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets> in its csproj (else blazor.web.js 404s).- Requires login — the mount always authorizes; anonymous visitors are redirected to the cookie login path. Pass a policy to also gate by role.
- Configurable path — defaults to
/auth/dashboard; pass any base path toMapKlassdAuthDashboardto mount it elsewhere. - Destructive ops — disable (revokes sessions), hard delete (cascades sessions/passkeys/metadata), and anonymize (GDPR erasure: strips PII but keeps the id row).
- Self-contained — a pipeline branch owning its routing/auth/antiforgery/static assets; ships its own stylesheet.
- Import page — when
AddAuthMigration()is registered, an/importpage imports an Auth0/SuperTokens export (or a live database the host registers viaAddConnectionSource) into a chosen tenant. It runs as a background job with live progress — survives navigating away, and is cancellable.
Webhooks (automate customer-service requests)
Let an external system (e.g. a support tool) disable, enable, delete or anonymize a user via an HMAC-signed request — so a "please close my account" ticket can be automated end-to-end:
auth.AddKlassdAuthWebhooks(o => o.SigningSecrets.Add(config["Auth:Webhooks:Secret"]!));
app.MapKlassdAuthWebhooks(); // POST /auth/webhooks/users
// The caller signs the request; Klassd.Auth verifies it (401 on forged/stale/unsigned):
// X-Klassd-Timestamp: <unix seconds>
// X-Klassd-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{body}">
// { "action": "disable"|"enable"|"delete"|"anonymize", "userId"|"email": "…", "reason": "…" } Verification mirrors the Klassd CMS outbound signing scheme and adds a timestamp tolerance (default 300s) to reject replays. Forged/stale/unsigned requests get 401; unknown users 404; each applied action is logged for an audit trail.
User metadata & roles
Everything app-specific lives in typed metadata — stored as one JSON document but accessed as typed sections, so two apps (CMS + Workflows) never collide. Roles use the same mechanism:
// One JSON document per user, accessed through typed sections so apps never collide:
await meta.SetAsync(userId, "cms:prefs", new CmsPrefs { Theme = "dark", Locale = "da" });
var prefs = await meta.GetAsync<CmsPrefs>(userId, "cms:prefs");
// Over HTTP:
// GET /auth/users/{id}/metadata → read the metadata JSON
// PATCH /auth/users/{id}/metadata → shallow-merge (null removes a key)
// Roles use the same mechanism, via RolesService:
await roles.SetRolesAsync(userId, ["Administrator"]);
var isAdmin = await roles.IsInRoleAsync(userId, "Administrator"); The User model carries the shared identity/lifecycle fields — Username (optional), PrimaryEmail, Disabled, and one or more LoginMethods (local password and/or external provider). Each app maps role strings to its own capability/permission model.
Admin user-management API
MapKlassdAuthAdmin(...) adds protected admin endpoints:
app.MapKlassdAuthAdmin(authorizationPolicy: "Admin");
// GET/POST /auth/admin/users
// GET /auth/admin/users/{id}
// POST /auth/admin/users/{id}/disable
// POST /auth/admin/users/{id}/reset-password
// GET/PUT /auth/admin/users/{id}/roles
// Responses never include password hashes.Token signing (rotating RS256 + JWKS by default)
With a storage adapter in play, Klassd.Auth defaults to rotating RS256 — keys are generated, persisted and auto-rotated, and the public keys are published — so discovery/JWKS validation works with no extra setup. With no store it falls back to HS256.
// Default: rotating RS256 when a storage adapter is present (keys persisted +
// auto-rotated, JWKS + discovery published). No call needed — the first key is
// generated automatically. Tune it, or choose another scheme:
auth.UseRotatingRsaSigning(o =>
{
o.SigningKeyLifetime = TimeSpan.FromDays(30);
o.ValidationGrace = TimeSpan.FromDays(7);
});
// auth.UseRsaSigning(rsa); // fixed RS256 key you supply
// auth.UseSharedSecretSigning(); // opt back into HS256 (shared secret, no JWKS)
// Either way, public keys are published at /auth/jwks.json and an OpenID Connect
// discovery doc at /auth/.well-known/openid-configuration — so a resource server
// auto-discovers the issuer + keys instead of hard-coding them:
builder.Services.AddAuthentication().AddJwtBearer(o =>
{
o.Authority = "https://your-host/auth"; // reads /auth/.well-known/openid-configuration
o.TokenValidationParameters.ValidAudience = "klassd.auth";
});- Default rotating RS256 —
UseRotatingRsaSigning(...)persists keys in the storage adapter and auto-rotates: the newest key signs, recently-retired keys keep validating during a grace window, and expired keys are pruned. It's on by default when a store is present; call it only to tune the lifetimes. - Fixed RS256 —
UseRsaSigning(rsa)/UseRsaSigning(pemString)with a key you supply. - Shared secret —
UseSharedSecretSigning()opts back into HS256 (no JWKS). - JWKS — public key(s) are published at
/auth/jwks.jsonso resource servers validate tokens without a shared secret (empty under HS256). - OIDC discovery — a discovery document at
/auth/.well-known/openid-configurationadvertises the issuer and an absolutejwks_uri, so JWT-bearer middleware auto-discovers the keys from anAuthorityinstead of hard-coding the JWKS URL.
Custom claims & sessions
Shape what's in the access token. An enricher runs on every issue — at sign-in and on each refresh — so derived claims (roles, tenant) stay current. To add a claim known at a point in time (say, captured from a provider), merge it into the session: it's persisted and rides every future token — the equivalent of SuperTokens' sessionContainer.MergeIntoAccessTokenPayload, and you can resolve the session straight from the request like GetSessionFromRequestContext.
// Add custom claims to every access token (issued at sign-in AND on each refresh):
auth.AddAccessTokenClaims(async (ctx, sp, ct) =>
(await sp.GetRequiredService<IRolesService>().GetRolesAsync(ctx.UserId, ct))
.Select(r => new Claim(ClaimTypes.Role, r)));
// Or merge claims into a live session — the SuperTokens MergeIntoAccessTokenPayload equivalent.
// Resolve the session from the request (like GetSessionFromRequestContext), then merge:
var session = await http.GetKlassdSessionAsync();
await session!.MergeIntoAccessTokenPayloadAsync(new { picture, roles }); // persists, rides every refresh- Typed values — strings become string claims; arrays/objects become real JSON claims (a
rolesstring[] lands as a JWT array, mapped toClaimTypes.Role). - Stamp on create —
AddSessionCreateHook(...)is handed the live session as each one is created (theCreateNewSessionoverride analogue). - Session data — entries set at sign-in are namespaced with an
sd_prefix by default (configurable, or off).
Overrides & hooks
Every core service is an interface with a delegating decorator base. Wrap any of them to inject your own logic and call base for the original — the same model as SuperTokens recipe-function overrides. The whole suite (HTTP endpoints, cookie sign-in, webhooks) resolves services by interface, so an override applies everywhere.
// Wrap any core service — the same model as SuperTokens recipe-function overrides.
auth.Override<IEmailPasswordService>((inner, sp) => new NoDisposableEmail(inner));
public sealed class NoDisposableEmail(IEmailPasswordService inner) : EmailPasswordServiceDecorator(inner)
{
public override Task<AuthResult> SignUpAsync(string email, string password, CancellationToken ct = default) =>
email.EndsWith("@tempmail.com", StringComparison.OrdinalIgnoreCase)
? Task.FromResult(new AuthResult(false, Error: "DISPOSABLE_EMAIL_BLOCKED"))
: base.SignUpAsync(email, password, ct); // call the original
}For third-party sign-in, a post-sign-in hook hands you the provider's tokens and the live session — so you can call the provider's APIs (e.g. fetch a profile picture) and merge claims onto the token, just like a SuperTokens post-sign-in-up override:
// JWT third-party sign-in. After the code exchange + session creation, the hook gets the
// provider tokens + the live session — mirrors a SuperTokens post-sign-in-up override.
app.MapKlassdThirdParty(); // GET .../authurl + POST .../signin
auth.AddProvider<AzureAdProvider>(); // your IThirdPartyProvider (exchange returns profile + tokens)
auth.AddThirdPartySignInHook(async (ctx, sp, ct) =>
{
var picture = await FetchGraphPhotoAsync(ctx.Tokens.AccessToken!, ct); // call the provider
await ctx.Session.MergeIntoAccessTokenPayloadAsync(new { picture, roles = ctx.Profile.Claims["roles"] });
});- Overridable — email/password, sessions, third-party, password reset, email verification, user accounts, lifecycle, roles, metadata and TOTP.
- Stackable — register more than one; the last wraps the previous.
- Per-provider mapping —
MapExternalProfile(scheme, …)overrides how one provider's claims map to a user (theGetUserInfoanalogue).
Migrating in (from Auth0 / SuperTokens)
Move an existing user base onto Klassd.Auth without forcing a password reset: bcrypt and argon2 hashes verify at login (then silently upgrade to the native pbkdf2 on next use). Social links, passwordless identities, roles, metadata and TOTP secrets carry over too. Runs are idempotent and dry-runnable.
// Import users INTO Klassd.Auth. bcrypt/argon2 passwords verify at login (then upgrade to
// pbkdf2 on next use); social links, roles, metadata and TOTP carry over. Idempotent.
builder.Services.AddKlassdAuth(cfg).UsePostgres(cs).AddAuthMigration();
var report = await runner.RunAsync(
new Auth0MigrationSource("auth0-export.json"),
new MigrationOptions { DryRun = true }); // verify first; set false to apply
// SuperTokens: a JSON export, or read the running core DB directly:
var src = new SuperTokensPostgresMigrationSource("Host=…;Database=supertokens;…"); // or …MySql- Sources — Auth0 bulk-export / import JSON, SuperTokens bulk-import JSON, or a SuperTokens core database read directly (PostgreSQL or MySQL).
- Safe to run in K8s — ship it as a one-shot Job/initContainer, or embed it in startup guarded by a durable ledger + distributed lease so it runs exactly once across replicas.
- Many databases → one multi-tenant instance — set
MigrationOptions.TenantId, or useRunManyAsync/ the CLI--tenant&--manifestto import each source into its own tenant. - Honest reporting — the report counts created / merged / skipped / failed, and flags any password that couldn't be migrated (so those users reset).
Multi-tenancy (shared-schema)
One instance can serve many tenants from one database. Each user and session carries a TenantId (default "public", so single-tenant deployments change nothing); the storage adapters scope every identity lookup — by email, username, phone and third-party provider — and the admin user list to the current tenant, and the access token carries it as a tnt claim. The same email can therefore register independently per tenant; lookup by id stays global.
app.UseAuthentication();
app.UseKlassdTenant(); // copies the access token's `tnt` claim into ITenantContext for the request
app.UseAuthorization();
// Identity lookups + the admin user list are scoped to ITenantContext.TenantId (default "public").
// Sign-up/sign-in bodies accept an optional "tenant"; the issued token carries it as the tnt claim.- Enforced in the store — so every login method (password, passwordless, passkey, OAuth) is tenant-scoped by construction; a login can never resolve another tenant's user.
- Carried in the token — no per-request subdomain/host parsing;
ITenantContextis set at login and rehydrated from thetntclaim byUseKlassdTenant(). - Migrate many into one — fold several source databases into one multi-tenant instance, each into its own tenant (see Migrating in).
Storage adapters
The core depends only on IUserStore, ISessionStore and IUserMetadataStore; pick a Data.* adapter to bind a database. All adapters use raw drivers, no EF/ORM, matching the Klassd house convention.
- SQLite —
UseSqlite("Data Source=…"), rawMicrosoft.Data.Sqlite, JSON-in-TEXT. Zero infrastructure for single-node deployments. - PostgreSQL —
UsePostgres("Host=…"), rawNpgsql,jsonbdocuments. - MongoDB —
UseMongoDb("mongodb://…"),MongoDB.Driver.
Add your own by implementing the store interfaces — exactly how the three Data.* packages do it.
Packages
Install the core plus the adapters you need — each keeps its SDK isolated, so you only pull in what you wire up. While in beta, add --prerelease.
| Package | Purpose |
|---|---|
Klassd.Auth.Abstractions | Store interfaces (IUserStore / ISessionStore / IUserMetadataStore) + DB-agnostic record types. No dependencies. |
Klassd.Auth.Core | The auth logic: email/password, sessions, third-party login, MFA, email verification, and metadata — storage-agnostic. AddKlassdAuth(). |
Klassd.Auth.AspNetCore | JSON/JWT HTTP delivery — one MapKlassdAuth() call wires the whole API (signup/signin/refresh/logout, MFA, email, metadata, JWKS). MapKlassdAuthAdmin() adds protected admin endpoints. |
Klassd.Auth.AspNetCore.Cookies | Cookie sign-in for server-rendered / Blazor apps + external-SSO & account-linking seam. AddKlassdAuthCookies() / UseKlassdAuthCookies(); optional loopback bypass. |
Klassd.Auth.Passwordless | Passwordless one-time codes over email and SMS. AddPasswordless() + MapKlassdPasswordless() (JSON and cookie variants). |
Klassd.Auth.Passkeys | Passkeys (WebAuthn / FIDO2) via Fido2NetLib, with a stateless DataProtection ceremony-challenge store. AddPasskeys() + MapKlassdPasskeys(). |
Klassd.Auth.Sms.Twilio | Twilio ISmsSender for passwordless-over-SMS delivery. AddTwilioSms(accountSid, authToken, fromNumber). |
Klassd.Auth.Dashboard | Drop-in Blazor (Interactive Server) user-admin dashboard — list/create/disable/delete/anonymize, roles, linked methods, plus a background import page (file or live DB) with live progress. AddKlassdAuthDashboard() + MapKlassdAuthDashboard(). |
Klassd.Auth.Webhooks | Inbound HMAC-signed webhooks so external tooling can disable/enable/delete/anonymize a user. AddKlassdAuthWebhooks() + MapKlassdAuthWebhooks(). |
Klassd.Auth.OpenIdConnect | OIDC external login, including Microsoft Entra ID (AddEntraId) and Google (AddGoogle), plus AddOpenIdConnect() for any other OIDC provider. |
Klassd.Auth.OAuth | OAuth 2.0 (non-OIDC) providers — GitHub, Facebook, Instagram, TikTok. Instagram/TikTok return no email. |
Klassd.Auth.Data.Sqlite | SQLite storage adapter (raw Microsoft.Data.Sqlite, JSON-in-TEXT) — zero infrastructure for single-node deployments. UseSqlite(). |
Klassd.Auth.Data.Postgres | PostgreSQL storage adapter (raw Npgsql, jsonb). UsePostgres(). |
Klassd.Auth.Data.MongoDb | MongoDB storage adapter (MongoDB.Driver). UseMongoDb(). |
Klassd.Auth.Migration | Import users into Klassd.Auth from Auth0 & SuperTokens JSON exports — passwords, social links, roles, metadata, TOTP. AddAuthMigration() + MigrationRunner; idempotent, dry-runnable, multi-replica-safe startup guard, and RunManyAsync to fold many databases into one multi-tenant instance. |
Klassd.Auth.Migration.SuperTokens.Postgres | Read a SuperTokens core database directly over PostgreSQL (Npgsql) by connection string — no export needed. |
Klassd.Auth.Migration.SuperTokens.MySql | Read a SuperTokens core database directly over MySQL (MySqlConnector) by connection string. |