Auth Projects
A Firebase Auth alternative — host your own app’s end-user pool on Sentroy. Email/password, 6 social provider federation (Google, GitHub, Facebook, Microsoft, X, Apple), passkey (WebAuthn), TOTP MFA, magic link, invitation flow, webhook delivery, self-service account management, and Sentroy-hosted UI included. Per-project RS256 JWT + JWKS publish + RFC 9700 refresh token rotation.
"Sign in with Sentroy" vs Auth Project#
The two products solve different problems — decide which one is right for you before you start.
| Sign in with Sentroy | Auth Project | |
|---|---|---|
| User base | Already has a Sentroy account | Your own user pool (Sentroy doesn’t know it) |
| Pattern | OAuth 2.0 / OIDC federation | Direct signup/login API + SDK |
| Flow | Redirect → consent → callback | Form submit → token returned |
| JWT | Sentroy global key (HS/RS256) | Per-project RS256 + JWKS |
| Branding | Sentroy consent screen | Entirely yours (logo, color, copy) |
| Similar product | Sign in with Google/Apple | Firebase Auth, Auth0, Clerk |
| SDK | Any OAuth lib (NextAuth, Authlib) | @sentroy-co/client-sdk/auth |
Quick decision: If you want your users to sign in with an existing Sentroy account (e.g. an internal tool) → Sign in with Sentroy. If you want to host your own user pool from scratch (consumer product, multi-tenant SaaS) → Auth Project, this page.
Setup#
Create an Auth Project in the Sentroy dashboard — then wire the SDK into your backend.
1. Log in to auth.sentroy.com and create a new project from the Auth Projects sidebar entry. The wizard walks you through these steps:
- Name + Slug — the slug appears in public URLs (
auth.sentroy.com/p/<slug>) and can’t be changed later. - Branding — primary color + display name + logo URL. Becomes your brand identity in the mail templates and on the verify-email / reset-password / login / signup landing pages.
- Email verification— when enabled, a user can’t log in after signup until they click the mail link.
- Magic link — passwordless login flow.
- Allowed origins (CORS) — the origins that will call the public auth API from the browser. If left empty, only server-to-server usage is allowed (browser CORS is rejected).
- Social providers — Google / GitHub / Facebook / Microsoft / X / Apple credentials (each optional). When triggered, a Sentroy-hosted authorize URL is generated.
The create response shows the plaintext API key (aps_...) once — copy it into your RP backend’s env (e.g. SENTROY_AUTH_API_KEY). Only the hash stays in the DB; you can’t recover the plaintext. If it leaks, rotate it from the dashboard.
Quickstart#
Install the SDK, initialize the project, run your first signup.
npm: npm install @sentroy-co/client-sdk (v2.13.9+ — auth module included)
For passkey support, an optional peer dep: npm install @simplewebauthn/browser
// app/lib/sentroy-auth.ts
import { SentroyAuth } from "@sentroy-co/client-sdk/auth"
export const auth = new SentroyAuth({
projectSlug: "acme-app",
apiKey: process.env.NEXT_PUBLIC_SENTROY_AUTH_API_KEY!,
storage: "localStorage", // "memory" | "localStorage" | custom adapter
})
// Firebase-style subscription
auth.onAuthStateChanged((user) => {
console.log(user ? "signed in: " + user.email : "signed out")
})
// Signup — if emailVerification is on, no tokens are returned, a mail is sent
await auth.signUp({ email: "alice@example.com", password: "hunter2-strong" })
// Login — MFA-aware discriminated union
const out = await auth.signIn({
email: "alice@example.com",
password: "hunter2-strong",
rememberMe: true, // extends the refresh token TTL (30d → 90d)
})
if (out.kind === "mfa") {
// Prompt the user for a TOTP code or recovery code
const code = prompt("MFA code")
await auth.verifyMfa({ mfaToken: out.data.mfaToken, code })
} else {
// out.data.user, out.data.accessToken ready
console.log("logged in:", out.data.user)
}
await auth.signOut()React SDK#
@sentroy-co/client-sdk/auth/react — Provider + 5 reactive hook.
SentroyAuthProvider holds a single SDK instance; all hooks run through it. On mount the provider restores from storage and consumes the social-login fragment (if present).
| Hook | Returns | Usage |
|---|---|---|
| useAuth() | { auth, user, loading, signIn, signUp, signOut, sendPasswordReset, verifyEmail, verifyMfa, sendMagicLink, consumeMagicLink, acceptInvitation, socialAuthorizeUrl, consumeRedirectFragment } | All auth actions + reactive user state |
| useUser() | SentroyAuthUser | null | When you only need the current user |
| useSessions() | { sessions, loading, error, refresh, revoke } | Active session list — security/devices page |
| useActivity() | { activity, loading, error, refresh } | Audit log — login/password-change/MFA/etc |
| useMfa() | { status, loading, error, refresh, enrollTotp, verifyTotpEnrollment, disableTotp } | TOTP enrollment + status |
| usePasskeys() | { passkeys, loading, error, refresh, register, remove } | Passkey list/register/delete (WebAuthn) |
"use client"
import { useAuth, useSessions, useMfa, usePasskeys } from "@sentroy-co/client-sdk/auth/react"
export function SecurityPage() {
const { user } = useAuth()
const { sessions, revoke } = useSessions()
const { status, enrollTotp, verifyTotpEnrollment, disableTotp } = useMfa()
const { passkeys, register, remove } = usePasskeys()
return (
<div>
<h2>Active sessions</h2>
{sessions?.map((s) => (
<div key={s.id}>
{s.userAgent} · {s.ip}
<button onClick={() => revoke(s.id)}>Revoke</button>
</div>
))}
<h2>Two-factor auth</h2>
{status?.enrolled ? (
<button onClick={() => disableTotp(currentPassword)}>Disable TOTP</button>
) : (
<button onClick={async () => {
const { secret, otpauthUri } = await enrollTotp()
// Render a QR code from otpauthUri; the user enters the 6-digit code:
await verifyTotpEnrollment(code)
}}>Enroll TOTP</button>
)}
<h2>Passkeys</h2>
<button onClick={() => register("MacBook Touch ID")}>Add passkey</button>
{passkeys?.map((p) => (
<div key={p.id}>
{p.deviceName} <button onClick={() => remove(p.id)}>Remove</button>
</div>
))}
</div>
)
}React Native / Expo#
@sentroy-co/client-sdk/auth works on Expo — you just pass a storage adapter. WebAuthn passkeys are web-only.
The SDK is built on a platform-agnostic core; React Native has no localStorage, so a storage adapter must be provided. Two common options: AsyncStorage (fast, not encrypted) or SecureStore (iOS Keychain / Android Keystore — recommended for long-lived secrets like the refresh token). For social login, expo-web-browser is used (in-app browser session).
Install: expo install @react-native-async-storage/async-storage expo-secure-store expo-web-browser
// app/lib/sentroy-auth.ts
import AsyncStorage from "@react-native-async-storage/async-storage"
import { SentroyAuth, type SentroyAuthStorage } from "@sentroy-co/client-sdk/auth"
function createAsyncStorageAdapter(): SentroyAuthStorage {
return {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
}
}
export const auth = new SentroyAuth({
projectSlug: "acme-app",
apiKey: process.env.EXPO_PUBLIC_SENTROY_AUTH_API_KEY!,
storage: createAsyncStorageAdapter(),
})
// Same SDK API — signIn/signUp/onAuthStateChanged are identical to Web:
auth.onAuthStateChanged((user) => {
console.log(user ? "signed in " + user.email : "signed out")
})Deep link configuration#
For expo-web-browser to return to your app from the in-app session, a scheme must be defined in app.config.ts. The myapp://auth/callbackURL resolves to the app’s deep link handler:
// app.config.ts
export default {
expo: {
name: "Acme",
slug: "acme-app",
scheme: "myapp", // <-- this makes myapp://... URLs open in the app
ios: { bundleIdentifier: "com.acme.app" },
android: { package: "com.acme.app" },
},
}In the Sentroy dashboard you need to add myapp:// to the Allowed origins list (in RN the origin is scheme-based; the full URL is not whitelisted).
Hydration race — loading guard#
On first mount the SDK rehydrates the token from storage — this is async. If you don’t show a splash/spinner, the user briefly sees “signed-out” and then instantly flips to “signed-in”. useAuth().loading exists for exactly this:
// app/App.tsx
import { SentroyAuthProvider, useAuth } from "@sentroy-co/client-sdk/auth/react"
import { ActivityIndicator, View } from "react-native"
import { auth } from "./lib/sentroy-auth"
function Root() {
const { user, loading } = useAuth()
if (loading) {
// Storage hasn't rehydrated yet — show a splash
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator />
</View>
)
}
return user ? <HomeStack /> : <AuthStack />
}
export default function App() {
return (
<SentroyAuthProvider client={auth}>
<Root />
</SentroyAuthProvider>
)
}Framework setup recipes#
The provider/init pattern for every popular framework — a copy-paste starting point.
The SDK usage API is the same across all frameworks; the only thing that changes is how you wrap the provider into your app shell. The recipes below show the fastest path.
// app/providers.tsx — Provider client component
"use client"
import { SentroyAuthProvider } from "@sentroy-co/client-sdk/auth/react"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SentroyAuthProvider
projectSlug="acme-app"
apiKey={process.env.NEXT_PUBLIC_SENTROY_AUTH_API_KEY!}
autoConsumeFragment
>
{children}
</SentroyAuthProvider>
)
}
// app/layout.tsx — Server component wraps Providers
import { Providers } from "./providers"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
// app/account/page.tsx — useAuth in any client component
"use client"
import { useAuth } from "@sentroy-co/client-sdk/auth/react"
export default function Account() {
const { user, loading, signOut } = useAuth()
if (loading) return <p>Loading…</p>
if (!user) return <a href="/login">Sign in</a>
return <button onClick={() => signOut()}>Sign out {user.email}</button>
}Magic link#
Passwordless login — clicking the link delivered by email establishes a session.
magicLinkEnabled: true must be set in the project settings. Email enumeration protection: the request always returns 200 (no mail is sent if no account exists).
// 1. Instead of a login form — request a magic link
await auth.sendMagicLink({
email: "alice@example.com",
redirectUri: "https://app.example.com/welcome",
})
// 2. Mail link: https://auth.sentroy.com/p/acme-app/magic?token=...
// The Sentroy-hosted page consumes it, OR the RP does on its own page:
// 3. The RP's /auth/magic callback page:
const tokenFromUrl = new URLSearchParams(location.search).get("token")!
const { user } = await auth.consumeMagicLink(tokenFromUrl)
// → session established, user state updatedMFA (TOTP)#
RFC 6238 time-based OTP — compatible with Google Authenticator, 1Password, Authy.
Flow: enrollTotp() → verifyTotpEnrollment(code) → on the user’s next login, signIn returns kind: "mfa" → verifyMfa({ mfaToken, code }).
// 1. Enrollment — the user adds the QR to their Authenticator app
const { secret, otpauthUri } = await auth.mfa.enrollTotp()
// otpauthUri = "otpauth://totp/Acme:alice@example.com?secret=...&issuer=Acme"
// Pass this to a QR code component; after the user scans it:
// 2. Enrollment confirm — with the 6-digit code
const { recoveryCodes } = await auth.mfa.verifyTotpEnrollment("123456")
// recoveryCodes: 10 one-time-use codes — SHOW them to the user and tell them to download/store them
// (in the forgot-totp flow, one of these codes is used for a recovery sign-in)
// 3. Next login flow — discriminated union
const out = await auth.signIn({ email, password })
if (out.kind === "mfa") {
const code = prompt("6-digit code")
await auth.verifyMfa({ mfaToken: out.data.mfaToken, code })
// or: await auth.verifyMfa({ mfaToken, recoveryCode: "..." })
}
// Disable — re-auth with the current password
await auth.mfa.disableTotp("currentPassword")Status check: await auth.mfa.getStatus() →{ enrolled, factorType, verifiedAt, recoveryCodesRemaining }.
Passkey / WebAuthn#
Passwordless, phishing-resistant authentication — Touch ID, Face ID, hardware key.
Two flows: register (adding a new passkey for a signed-in user) and authenticate (sign-in with a passkey).
// A registered user adds a new passkey
await auth.passkey.register("MacBook Touch ID")
// → browser WebAuthn prompt; on success it's added to the passkey list
// List + delete
const keys = await auth.passkey.list()
// [{ id, credentialIdPrefix, deviceName, transports, lastUsedAt, createdAt }]
await auth.passkey.delete(keys[0].id)
// Passwordless sign-in
const { user } = await auth.passkey.authenticate({
email: "alice@example.com", // optional — if present, that user's passkeys are allow-listed
rememberMe: true,
})
// → session establishedReact: the usePasskeys() hook provides list + register + remove reactively (automatic refresh after a mutation).
Invitation flow#
An admin invites someone else into your user pool — activation + password set via a mail link.
An invitation is sent from the Users page in the dashboard via Invite user. The invited user receives a mail: https://auth.sentroy.com/p/<slug>/invitation?token=...(Sentroy-hosted), or the RP can consume it on its own page.
// The RP's /invitation/accept page — the token comes from the URL
const token = new URLSearchParams(location.search).get("token")!
const { user } = await auth.acceptInvitation({
token,
password: "newPasswordChosen",
displayName: "Alice",
})
// → account created + session established, redirect homeTo generate an invitation with the server-side admin SDK, the dashboard endpoint is used (POST /api/companies/{slug}/auth-projects/{id}/invitations — cookie auth). An invite-create endpoint in the public API layer with aps_ is planned for v2.
Self-service /me endpoints#
Users managing their own account — change password/email, delete account, view sessions/activity.
All /me/*endpoints are authenticated with the user’s access token (user JWT). Calling them via the SDK:
// Profile + membership
const me = await auth.getCurrentUser() // live DB read
const sessions = await auth.listSessions() // active sessions
await auth.revokeSession(sessionId) // close a specific session
// Change password — revokes all sessions + clears the local session
await auth.changePassword({ currentPassword, newPassword })
// Change email — a confirmation mail goes to the new address
await auth.requestEmailChange({ newEmail, currentPassword })
// When the user clicks the mail:
await auth.confirmEmailChange(tokenFromMailLink)
// → email update + revoke all sessions + local clear
// Delete account — two-step (confirmation mail)
await auth.requestAccountDeletion(currentPassword)
// Mail link:
await auth.confirmAccountDeletion(tokenFromMailLink)
// → account hard-delete, clear the local session
// Activity log — login/password-change/email-change/MFA/passkey/social events
const activity = await auth.getActivity()
// [{ id, action, ipAddress, createdAt, details }]React: using the useSessions() and useActivity() hooks is the cleanest — automatic refresh after a mutation.
User data management#
Sentroy keeps the auth essentials; app-specific data lives in your DB — strategy and sync pattern.
Sentroy stores only the fields required for auth: id, email, emailVerified, displayName, image, locale, metadata (~16KB cap), createdAt, lastLoginAt, lastLoginIp. Your subscriptions, orders, posts, user-preference JSON — these stay in your DB and are foreign-keyed to Sentroy via the sub claim (= user.id).
Sentroy vs you — who keeps what#
| Sentroy owns | You own (mirror in your DB) |
|---|---|
id — JWT sub claim | users.sentroy_user_id (FK, indexed, unique) |
email, emailVerified | Subscription, billing, plan, role, permissions |
displayName, image | User-generated content (posts, comments, files) |
locale (UI hint) | App preferences, notification settings, theme |
metadata — JSON ≤16KB cap, small flags | Profile detail, address, phone, social handles (bulk) |
lastLoginAt, lastLoginIp | Activity log, audit trail (app-specific events) |
| Sessions, MFA factors, passkeys, recovery codes | Anything >16KB or relational (orders, projects, teams) |
Rule:if it isn’t useful as a JWT claim or doesn’t directly touch the authentication UX — keep it in your DB.
Source-of-truth + webhook mirror#
Standard pattern: in the user.signup webhook, create a row in your own users table with sentroy_user_id as the join key. In the user.account-deleted webhook, cascade delete (or soft-delete, depending on GDPR):
// app/api/webhooks/sentroy-auth/route.ts
import { createHmac, timingSafeEqual } from "node:crypto"
import { db } from "@/lib/db"
export async function POST(req: Request) {
const sig = req.headers.get("X-Sentroy-Signature") ?? ""
const body = await req.text()
const expected =
"sha256=" +
createHmac("sha256", process.env.SENTROY_WEBHOOK_SECRET!)
.update(body)
.digest("hex")
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return new Response("bad signature", { status: 401 })
}
const event = JSON.parse(body) as
| { event: "user.signup"; data: { userId: string; email: string } }
| { event: "user.account-deleted"; data: { userId: string } }
| { event: "user.email-changed"; data: { userId: string; email: string } }
switch (event.event) {
case "user.signup":
await db.user.create({
data: {
sentroyUserId: event.data.userId,
email: event.data.email,
createdAt: new Date(),
},
})
break
case "user.email-changed":
await db.user.update({
where: { sentroyUserId: event.data.userId },
data: { email: event.data.email },
})
break
case "user.account-deleted":
// Cascade delete — orders, posts, comments, all FKs
await db.user.delete({ where: { sentroyUserId: event.data.userId } })
break
}
return new Response(null, { status: 200 })
}First request (lazy provisioning): if the webhook arrives late or is missed, do an upsert on the first authenticated API request when the row is missing. You already have the JWT sub + email.
user.metadata — when to use it?#
metadata is for small flags that go into the JWT or touch the auth UX during signup: onboarding state, plan tier (custom claim target), invitation source, marketing opt-in.
// Good usage
await auth.signUp({
email: "alice@example.com",
password: "...",
metadata: {
onboarded: false, // app routes to the onboarding wizard
plan: "trial", // custom claim → copied into the JWT
invitedBy: "alice@x.com", // attribution
marketingOptIn: true,
},
})
// BAD usage — don't send bulk data to Sentroy
await auth.updateProfile({
metadata: {
addressBook: [...], // 50KB JSON — exceeds the 16KB cap
sessionHistory: [...], // an append-only log isn't Sentroy's job
creditCardLast4: "4242", // PCI scope target, keep it in your own DB
},
})GDPR — right to erasure + portability#
On the Sentroy side the user has self-service account deletion: POST /api/v1/auth/<slug>/me/account/delete-request → confirmation mail → delete-confirm hard-delete. A webhook fires; your DB cleanup is handled by the sync handler above.
Full data export (GDPR Article 20): on the Sentroy side GET /me returns JSON; on your side, join on sentroy_user_idand serialize all of the user’s rows to JSON — email it or offer a download button via a signed URL.
// app/api/me/export/route.ts
import { db } from "@/lib/db"
import { admin } from "@/lib/sentroy-admin"
export async function GET(req: Request) {
const token = req.headers.get("authorization")?.replace(/^Bearer\s+/, "")
const claims = await admin.verifyIdToken(token!)
const sentroyMe = await admin.users.get(claims.sub) // live profile
const ourRows = await db.user.findUnique({
where: { sentroyUserId: claims.sub },
include: { orders: true, posts: true, preferences: true },
})
return Response.json({
sentroy: sentroyMe,
application: ourRows,
exportedAt: new Date().toISOString(),
})
}Inactive user cleanup#
Sentroy currently does not auto-prune inactive users. If you want auto-cleanup: CSV export from the dashboard Users page → lastLoginAt < 18 months ago filter → batch admin.users.delete(id)with the server-to-server admin SDK. An “auto-delete after N days inactive” policy is planned for v2.
Common soft-inactivity pattern:send a mail warning (“90 days inactive — log in to keep account”), wait another 30 days, then delete. Your application triggers the mail (Sentroy’s activity webhook + a cron job).
Sentroy-hosted UI#
For those who want to avoid writing forms — branded login/signup/verify/reset pages.
For each project, Sentroy automatically hosts these pages (with your branding applied):
| Path | Purpose |
|---|---|
| /p/<slug>/login | Email+password + social + magic link + MFA in one form |
| /p/<slug>/signup | New account form |
| /p/<slug>/verify-email | Mail link landing (token consume) |
| /p/<slug>/reset-password | Password reset form |
| /p/<slug>/magic | Magic link consume |
| /p/<slug>/invitation | Invitation accept + password set |
| /p/<slug>/account | Self-service account (sessions, MFA, passkey, email/password change, delete account) |
The RP redirects back to its own page by adding a ?redirectUri=...param. Auth tokens are returned in the fragment (the RP’s SDK picks them up with consumeRedirectFragment()).
Webhooks#
Auth events (signup, login, password-changed, etc.) HTTP POSTed to your endpoint.
Create from the Webhooks tab in the dashboard. An HMAC-SHA256 secret is generated for each webhook (plaintext shown once). You subscribe to topics, or if left empty, all are sent.
Topics
user.signup— new registrationuser.login— successful login (every time)user.password-changed— self-service change or resetuser.email-changed— confirmed email changeuser.account-locked— 5 failed logins → 15min lockuser.account-deleted— self-service or admin
Payload format
POST https://yourapp.com/webhooks/sentroy-auth
Content-Type: application/json
User-Agent: sentroy-auth-webhook/1.0
X-Sentroy-Event: user.signup
X-Sentroy-Signature: sha256=<hex-hmac>
X-Sentroy-Delivery-Id: dlv_<random>
{
"event": "user.signup",
"timestamp": "2026-05-18T12:34:56.789Z",
"data": {
"userId": "...",
"email": "alice@example.com",
"emailVerified": false,
"provider": "email" // or "google" | "github" | ...
}
}Signature verify (Node)
import { createHmac, timingSafeEqual } from "node:crypto"
export async function POST(req: Request) {
const sig = req.headers.get("X-Sentroy-Signature") // "sha256=..."
const body = await req.text()
const expected = "sha256=" + createHmac("sha256", process.env.SENTROY_WEBHOOK_SECRET!)
.update(body)
.digest("hex")
const a = Buffer.from(sig ?? "")
const b = Buffer.from(expected)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 })
}
const event = JSON.parse(body)
// ... process event.data
return new Response(null, { status: 200 })
}Retry: 3 attempts (0s / 2s / 10s backoff). 4xx (except 429) → deterministic fail, no retry. 5xx and network errors → retry. All attempts are logged to the auth_project_webhook_deliveries collection with a 30-day TTL; visible in the dashboard Webhook deliveries tab.
REST endpoints#
If you're not using the SDK, direct HTTP — all endpoints live under /api/v1/auth/[slug]/...
Auth modes: aps_ = project API key (server-only master), user = end-user access token, none = single-use token (already secret).
| Method | Path | Auth | Use |
|---|---|---|---|
| POST | /signup | aps_ | New user |
| POST | /login | aps_ | Tokens or MFA challenge |
| POST | /login/mfa/verify | aps_ | TOTP code or recovery code |
| POST | /refresh | aps_ | Token rotation (RFC 9700) |
| POST | /logout | aps_ | Refresh token revoke |
| POST | /verify-email | none | Email verify token consume |
| POST | /password-reset/request | aps_ | Send reset mail |
| POST | /password-reset/confirm | none | Token + new password |
| POST | /magic-link/request | aps_ | Send magic mail |
| POST | /magic-link/consume | aps_ | Magic token → login |
| POST | /invitation/accept | aps_ | Invitation token + password |
| GET | /social/{provider}/authorize | none | OAuth redirect URL |
| GET/POST | /social/{provider}/callback | none | Provider callback (Apple POST) |
| POST | /passkey/authenticate/begin | aps_ | WebAuthn challenge |
| POST | /passkey/authenticate/complete | aps_ | Assertion → login |
| GET | /me | user | Profil (live DB) |
| GET | /me/sessions | user | Active sessions |
| DELETE | /me/sessions/{id} | user | Session revoke |
| POST | /me/password | user | Change password |
| POST | /me/email/change-request | user | Email change mail |
| POST | /me/email/change-confirm | user | Email change via token |
| POST | /me/account/delete-request | user | Delete account mail |
| POST | /me/account/delete-confirm | none | Hard-delete via token |
| GET | /me/activity | user | Audit log |
| GET | /me/mfa | user | MFA status |
| POST | /me/mfa/totp/enroll | user | TOTP secret + URI |
| POST | /me/mfa/totp/verify-enrollment | user | Code + recovery codes |
| POST | /me/mfa/totp/disable | user | Re-auth + TOTP off |
| GET | /me/passkey | user | Passkey list |
| DELETE | /me/passkey/{id} | user | Passkey delete |
| POST | /me/passkey/register/begin | user | WebAuthn create challenge |
| POST | /me/passkey/register/complete | user | Save attestation |
| GET | /userinfo | user | OIDC userinfo (claims) |
| GET | /jwks.json | none | Project public key set |
Below are TypeScript SDK / cURL / Python examples for the 8 most frequently used endpoints. aps_ = project master API key (server-only), <access-token> = end-user JWT. All paths live under https://auth.sentroy.com/api/v1/auth/<your-project-slug>/....
POST /signup — new user#
/api/v1/auth/{slug}/signupBody: email (required, string), password (required, ≥8 chars, HIBP-checked), displayName (optional), locale (optional, "tr"/"en"), metadata (optional, JSON ≤16KB). If email verification is on, the response returns no token — a mail link is expected.
import { SentroyAuth } from "@sentroy-co/client-sdk/auth"
const auth = new SentroyAuth({
projectSlug: "acme-app",
apiKey: process.env.SENTROY_AUTH_API_KEY!,
})
const out = await auth.signUp({
email: "alice@example.com",
password: "hunter2-strong",
displayName: "Alice",
metadata: { plan: "trial" },
})
// if emailVerification is on: out.kind === "verification-required"
// if off: out.kind === "tokens" → out.data.accessToken readyPOST /login — tokens or MFA challenge#
/api/v1/auth/{slug}/loginBody: email, password (required), rememberMe (optional bool; refresh TTL 30d → 90d). Response discriminated union: if MFA is enrolled, kind: "mfa" + mfaToken is returned, then /login/mfa/verify is called.
const out = await auth.signIn({
email: "alice@example.com",
password: "hunter2-strong",
rememberMe: true,
})
if (out.kind === "mfa") {
const code = prompt("6-digit code from authenticator app")!
await auth.verifyMfa({ mfaToken: out.data.mfaToken, code })
} else {
// out.data.user, out.data.accessToken, out.data.refreshToken
console.log("Signed in:", out.data.user.email)
}POST /refresh — rotation#
/api/v1/auth/{slug}/refreshRFC 9700 family-based rotation. The old refresh token is used once; a second use triggers reuse detection and the entire family is revoked. A new access + refresh pair is returned.
// The SDK refreshes automatically — no manual call needed:
const me = await auth.getCurrentUser() // on a 401 the SDK refreshes + retries internally
// Manual (for a custom backend):
const { accessToken, refreshToken } = await auth.refresh(currentRefreshToken)POST /logout — refresh revoke#
/api/v1/auth/{slug}/logoutRevokes the refresh token (and its family root). The access token stays valid until its TTL expires (1 hour) — for critical logouts where you also need to blacklist the access token, use POST /me/sessions/<id> DELETE.
await auth.signOut()
// → POST /logout + clear local storage + onAuthStateChanged(null) fireGET /userinfo — OIDC claims#
/api/v1/auth/{slug}/userinfoOIDC standard userinfo endpoint. Auth: end-user access token (Bearer JWT). aps_ is not accepted here. The difference from GET /me: /userinfo returns OIDC claim names (sub, email_verified), /me returns the SDK profile shape.
// If you use the SDK, auth.getCurrentUser() is enough (SDK shape).
// If you want the OIDC claim shape, raw fetch:
const accessToken = auth.getAccessToken()
const resp = await fetch(
"https://auth.sentroy.com/api/v1/auth/acme-app/userinfo",
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
const claims = await resp.json()
// { sub, email, email_verified, name, picture, locale, ... }POST /verify-email — token consume#
/api/v1/auth/{slug}/verify-emailAuth: none — the token is already secret (single-use, short-lived). Body: token (required, from the mail link). On success it returns emailVerified: true + auto-login tokens.
// On the /verify-email landing page:
const token = new URLSearchParams(location.search).get("token")!
const { user, accessToken } = await auth.verifyEmail(token)
// → emailVerified=true + signed inPOST /password-reset/{request,confirm}#
/api/v1/auth/{slug}/password-reset/request/api/v1/auth/{slug}/password-reset/confirmTwo steps: request (send mail — email enumeration protection, always 200) → the user clicks the mail link → confirm (token + new password, the new password is HIBP-checked). On a successful confirm, all sessions are revoked + auto-login.
// 1. Request reset
await auth.sendPasswordReset({
email: "alice@example.com",
redirectUri: "https://app.example.com/reset",
})
// → always success (silent no-op if no account)
// 2. On the /reset page, from the token URL param:
const token = new URLSearchParams(location.search).get("token")!
const { user, accessToken } = await auth.confirmPasswordReset({
token,
newPassword: "newSecurePass123",
})GET /jwks.json — for server-side verify#
/api/v1/auth/{slug}/jwks.jsonPublic RSA key set (per-project). Auth: none — public key. When your RP backend verifies the JWT, it caches this endpoint (default TTL 1h, aligned with the rotation grace) and matches by kid. The SDK’s SentroyAuthAdmin.verifyIdToken method handles this automatically.
import { SentroyAuthAdmin } from "@sentroy-co/client-sdk/auth/admin"
const admin = new SentroyAuthAdmin({
projectSlug: "acme-app",
apiKey: process.env.SENTROY_AUTH_API_KEY!,
jwksCacheTtl: 3600, // seconds — default 1h
})
// JWKS auto fetch + cache + kid match + signature verify
const claims = await admin.verifyIdToken(accessToken)
// claims.sub, claims.email, claims.iss, claims.aud, claims.exp
// Manual JWKS pull (debugging / custom verify):
const jwks = await fetch(
"https://auth.sentroy.com/api/v1/auth/acme-app/jwks.json"
).then((r) => r.json())
// { keys: [{ kty: "RSA", kid: "...", n: "...", e: "AQAB", alg: "RS256" }, ...] }ID token claims#
The access token is an RS256-signed JWT — with a per-project key.
The SDK’s verifyIdToken() method fetches the JWKS, matches by kid, and checks the signature + iss + aud + exp. The JWKS cache TTL defaults to 1 hour (aligned with the rotation grace period); manual invalidation: admin.invalidateJwksCache().
{
"sub": "auth-project-user-id", // user id
"email": "alice@example.com",
"email_verified": true,
"name": "Alice", // displayName
"picture": "https://...", // image URL
"iss": "https://auth.sentroy.com/p/acme-app",
"aud": "aps_a1b2c3d4e5f6", // project API key prefix
"iat": 1733000000,
"exp": 1733003600, // 1 hour TTL
// If customClaims are set:
"plan": "pro", // staticClaims
"orgId": "org_xyz" // fromMetadata
}Custom JWT claims#
Add fields to the access token from user metadata or static values — the RP backend uses them without an extra DB call.
Configure from Settings → Custom claims in the dashboard. Two types:
- From metadata — whitelist top-level keys; that key in user.metadata is copied into the JWT. Example: user.metadata.orgId = "org_xyz" + whitelist ["orgId"] → the
orgIdclaim is set. - Static claims — added to every user as a constant (e.g. project version tag, deployment env).
aud/iss/subcan’t be overridden.
Update metadata: edit in the user detail in the dashboard, or PATCH /api/companies/{slug}/auth-projects/{id}/users/{userId}.
Email templates#
Verify/reset/magic/invitation/new-device-alert/account-locked mails are sent with your project branding.
Mails go out from Sentroy’s system mail platform via noreply@auth.sentroy.com (custom from-domain in v2). Default templates are in the tr + en locales. An override can be written for each template from the Dashboard → Emails tab (LocalizedField — TR/EN tabs, the same widget):
verify-emailpassword-resetmagic-linkinvitationnew-device-alert— when lastLoginIp changesaccount-locked— 5 failed login lockoutemail-change-confirmaccount-delete-confirm
If there’s no override, the Sentroy default template is rendered (with the project branding placeholders). Placeholders:
{projectName}— branding.displayName{primaryColor}— CTA button color{logoUrl}— logo (text fallback if absent){userEmail}— recipient address{verifyUrl}/{resetUrl}/{magicUrl}/{invitationUrl}— action URLs
Migration from other auth providers#
CSV import to move your existing user pool from Auth0/Firebase/Cognito to Sentroy.
A CSV is uploaded with the Users → Import button in the dashboard. Format:
# users.csv
email,passwordHash,passwordAlgo,emailVerified,displayName,metadata
alice@example.com,scrypt$N$r$p$salt$hash,scrypt,true,Alice,"{""plan"":""pro""}"
bob@example.com,$argon2id$v=19$...,argon2id,true,Bob,"{}"
carol@example.com,,,false,Carol,"{}"
# If no hash: the user is imported in a "password reset required" state;
# a reset mail is sent on the first login attempt.Supported hash formats: scrypt (native), argon2id, bcrypt (transparent migration — re-hashed to scrypt on the first login). An adapter script for the differing hash formats of Auth0/Firebase is available in the SDK examples.
During import the user.signup webhook is not triggered (for bulk migration). If you want it triggered, check the Trigger webhooks checkbox.
User pool management (dashboard)#
auth.sentroy.com → company → Auth Projects → [project] → Users.
Dashboard tabs: Overview (MAU + recent signups chart), Users (paginated list, search, email-verified filter, per-user revoke/delete), Activity (audit log), Webhooks, Emails (template overrides), Settings (branding, password policy, allowed origins, custom claims, JWT key rotation, social providers, magic link toggle, email verification toggle, plan/quota),API keys.
Dashboard endpoints work with cookie auth (cross-subdomain .sentroy.com). For the RP’s server-to-server user management, the aps_ public API is used — a full public API layer for the admin endpoints is planned for v2.
Security#
v1.62.106+ — production-ready posture.
- Password hash: scrypt N=2^16 (OWASP minimum, pure Node — no native binding required). The format is self-describing:
scrypt$N$r$p$salt$hash. Transparent migration for Argon2id + bcrypt imports. - HaveIBeenPwned check: on signup and password-reset confirm, the new password is queried against the HIBP k-anonymity API (first 5 chars of the hash). Breached passwords are rejected.
- Failed login lockout: after 5 failed attempts the account is locked for 15 minutes +
account-lockedwebhook + mail. The lockout window is per user; not per session/IP. - Email enumeration: password reset and magic link request always return 200 — silent no-op if no account exists. Signup returns an explicit 409 (DX trade-off); tightening in v2.
- Rate limit: per-IP signup 5/min, login 20/min, password-reset 3/min. On exceeding: 429 + Retry-After header.
- Refresh token rotation: RFC 9700 family-based. Reuse detection → revoke the whole family + audit log (
auth-project.refresh.reuse-detected). Remember-me TTL: 30d default, 90d with remember-me. - CORS: the project’s
allowedOriginslist is authoritative. Empty = browser calls are rejected (server-to-server only). - JWT signing: per-project RSA 2048-bit keypair. The public key is published in the JWKS; the private key is AES-GCM encrypted in the DB, decrypted only at sign time. 2-slot rotation is supported: the new key is used for issuing, the old key stays in a grace period (verify-only) → the JWKS publishes both.
- Social provider secrets: ClientSecret + Apple p8 privateKey are AES-256-GCM encrypted in the DB; decrypted with
ENV_VAULT_MASTER_KEY. - Webhook secret: plaintext in the DB (needed for HMAC verify) — in the DB-compromise threat model everything is already compromised; encrypting it adds no defense in depth.
- Quota: free tier 5K MAU + 100 signups/hour (atomic counter, enforced at the signup endpoint). On exceeding: 429. Paid tier unlimited.
v2 epics (not done yet)#
Currently v1.62.106 — production-ready. Known gaps:
- Custom domain (
auth.customer.comCNAME) - Public admin API layer (users.list/get/update/delete with
aps_— currently dashboard cookie-auth only) - RBAC per-project (custom roles + permission system)
- Anonymous users (Firebase pattern — guest → upgrade)
- SMS MFA (TOTP already exists, an SMS factor is added)
- Browser-safe public key tier (admin key separation)
- Email enumeration protection (uniform signup response timing)
- Stripe billing integration (self-service free→paid plan upgrade)
- Webhook delivery retry policy customization (current: fixed 3-attempt)
Social federation#
6 provider — Google, GitHub, Facebook, Microsoft, X (Twitter), Apple. Per-project credentials, Sentroy-hosted callback.
For each provider the RP defines its own OAuth client in the dashboard (clientId + secret; for Apple teamId/keyId/p8 privateKey). The Sentroy callback URL is fixed for every provider:
https://auth.sentroy.com/api/v1/auth/<slug>/social/<provider>/callbackProvider notes
common, a tenant UUID for B2B Entra.<username>@x.localplaceholder (the user can update the email later).response_mode=form_postflow.Authorize flow (browser)
Authorize endpoint (manual)