Security Plugin (sentinel)
The `sentinel()` plugin provides comprehensive security and abuse protection for your authentication system. It detects and prevents various attack vectors including credential stuffing, impossible travel, free trial abuse, and more.
Installation
import { betterAuth } from "better-auth";
import { sentinel } from "@better-auth/infra";
export const auth = betterAuth({
plugins: [
sentinel({
security: {
// Configure security features here
},
}),
],
});Configuration Options
SentinelOptions
| Option | Type | Description |
|---|---|---|
apiUrl | string | Better Auth infrastructure API URL. Default: https://dash.better-auth.com |
kvUrl | string | Better Auth identification infrastructure URL. Default: https://kv.better-auth.com |
apiKey | string | Your API key for authentication. Falls back to BETTER_AUTH_API_KEY in env. |
apiOptions | object | Dash API HTTP options. Accepts timeout?: number (ms, default 3000) and headers?: Record<string, string>. |
kvOptions | object | KV HTTP options. Accepts timeout?: number (ms, default 1000), retry?: { attempts?: number; baseDelay?: number; maxDelay?: number }, and headers?: Record<string, string>. |
apiTimeout | number | Deprecated alias for apiOptions.timeout. |
kvTimeout | number | Deprecated alias for kvOptions.timeout. |
sentinelHeaders | "legacy" | "modern" | Header names for identification and PoW. Default: "legacy". Must match the client. See Protocol headers. |
security | SecurityOptions | Security feature configuration. |
SecurityOptions
interface SecurityOptions {
unknownDeviceNotification?: boolean;
credentialStuffing?: CredentialStuffingConfig;
impossibleTravel?: ImpossibleTravelConfig;
geoBlocking?: GeoBlockingConfig;
botBlocking?: boolean | { action: SecurityAction };
suspiciousIpBlocking?: boolean | { action: SecurityAction };
velocity?: VelocityConfig;
freeTrialAbuse?: FreeTrialAbuseConfig;
compromisedPassword?: CompromisedPasswordConfig;
emailValidation?: EmailValidationConfig;
emailNormalization?: { enabled?: boolean };
staleUsers?: StaleUsersConfig;
challengeDifficulty?: number;
}
type SecurityAction = "log" | "challenge" | "block";Security Features
Credential Stuffing Protection
Detects and blocks credential stuffing attacks by tracking failed login attempts per visitor.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
credentialStuffing: {
enabled: true,
action: "challenge", // optional default action; thresholds still drive challenge vs block
thresholds: {
challenge: 3, // Issue PoW challenge after 3 failures
block: 5, // Block after 5 failures
},
windowSeconds: 3600, // 1 hour window
cooldownSeconds: 900, // 15 minute cooldown after block
},
},
}),How it works:
- Tracks failed login attempts per visitor ID
- After reaching the challenge threshold, issues a Proof-of-Work challenge
- After reaching the block threshold, blocks the visitor entirely
- Automatically clears failed attempts on successful login
Impossible Travel Detection
Detects logins from geographically distant locations in impossibly short timeframes.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
impossibleTravel: {
enabled: true,
maxSpeedKmh: 1000, // Max realistic travel speed
action: "challenge", // "log", "challenge", or "block"
},
},
}),Example: If a user logs in from New York and then 30 minutes later from Tokyo, this would be flagged as impossible travel (would require traveling faster than 1000 km/h).
Free Trial Abuse Prevention
Prevents users from creating multiple accounts to abuse free trials using device fingerprinting.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
freeTrialAbuse: {
enabled: true,
thresholds: {
challenge: 2,
block: 3,
},
maxAccountsPerVisitor: 3,
retentionSeconds: 90 * 24 * 60 * 60, // optional; default 90 days
action: "block",
},
},
}),How it works:
- Tracks account creations per visitor fingerprint
- When threshold is exceeded, blocks new account creation
- Useful for preventing free tier abuse
retentionSeconds controls how long per-visitor signup ledger keys are kept (sliding window). Default is 90 days; the platform clamps values to between 1 day and 365 days.
Compromised Password Detection
Checks passwords against the HaveIBeenPwned database to detect compromised credentials.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
compromisedPassword: {
enabled: true,
action: "block", // "log", "challenge", or "block"
minBreachCount: 1, // Minimum breaches to trigger
},
},
}),Privacy: Uses k-anonymity - only the first 5 characters of the password hash are sent to the API, never the full password.
Stale Account Monitoring
Detects when dormant accounts suddenly become active, which could indicate account takeover.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
staleUsers: {
enabled: true,
staleDays: 90, // Account considered stale after 90 days
action: "log", // "log", "challenge", or "block"
notifyUser: true, // Send email to user
notifyAdmin: true, // Send email to admin
adminEmail: "admin@yourapp.com",
},
},
}),Notifications include:
- Login time and location
- Days since last activity
- Device information
Geo-Blocking
Block or challenge users from specific countries.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
geoBlocking: {
allowList: ["US", "CA", "GB"], // Only allow these countries
// OR
denyList: ["XX", "YY"], // Block these countries
action: "block", // "challenge" or "block"
},
},
}),Use ISO 3166-1 alpha-2 country codes.
Bot Blocking
Detect and block automated bot traffic.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
botBlocking: true,
// OR with custom action
botBlocking: {
action: "challenge", // "log", "challenge", or "block"
},
},
}),Suspicious IP Detection
Block requests from known malicious IP addresses.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
suspiciousIpBlocking: true,
// OR with custom action
suspiciousIpBlocking: {
action: "block",
},
},
}),Velocity / Rate Limiting
Limit the rate of various operations.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
velocity: {
enabled: true,
thresholds: {
challenge: 10,
block: 20,
},
maxSignupsPerVisitor: 5,
maxPasswordResetsPerIp: 10,
maxSignInsPerIp: 50,
windowSeconds: 3600,
action: "challenge",
},
},
}),Email Validation
Block disposable email addresses and validate email domains.
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
emailValidation: {
enabled: true,
strictness: "medium", // "low", "medium", or "high"
action: "block",
domainAllowlist: ["company.com", "partner.org"], // always allow these domains
},
},
}),Strictness levels:
low- Block only known disposable domainsmedium- Also check for valid MX recordshigh- Additional heuristic checks
domainAllowlist skips disposable/heuristic checks for the listed domains (exact domain match).
Email normalization
Sentinel can normalize email addresses before sign-up and sign-in so aliases and provider quirks do not create duplicate accounts or mismatched logins. Normalization includes lowercasing, stripping plus-address tags on common providers (for example user+tag@gmail.com → user@gmail.com), removing dots in Gmail-style addresses, and mapping googlemail.com to gmail.com.
Use security.emailNormalization when you want to control this separately from disposable-domain validation (emailValidation):
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
emailValidation: {
enabled: false, // skip disposable / MX checks
},
emailNormalization: {
enabled: true, // still normalize for deduplication and consistent sign-in
},
},
}),Protocol headers (sentinelHeaders)
Sentinel carries visitor identity and PoW challenges over HTTP headers. The default "legacy" names (X-Request-Id, X-Visitor-Id, X-PoW-*) are sometimes rewritten by proxies. Set sentinelHeaders: "modern" for proxy-safe X-Sentinel-* names instead.
Available on sentinel(), sentinelClient(), and sentinelNativeClient(). Default: "legacy". Match the value on server and client. Writers use only the configured family; readers accept either.
| Role | "legacy" (default) | "modern" |
|---|---|---|
| Request id | X-Request-Id | X-Sentinel-Request-Id |
| Visitor id | X-Visitor-Id | X-Sentinel-Visitor-Id |
| PoW challenge | X-PoW-Challenge | X-Sentinel-PoW-Challenge |
| PoW reason | X-PoW-Reason | X-Sentinel-PoW-Reason |
| PoW solution | X-PoW-Solution | X-Sentinel-PoW-Solution |
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
sentinelHeaders: "modern",
})sentinelClient({
sentinelHeaders: "modern",
autoSolveChallenge: true,
})sentinelNativeClient({
sentinelHeaders: "modern",
autoSolveChallenge: true,
})Cross-origin CORS
Same-origin browser apps and native clients do not need CORS configuration for Sentinel headers.
Cross-origin browser clients do. Visitor-id and PoW headers are non-simple, so the browser will not send or read them unless your auth API lists the exact names — not Access-Control-Allow-Headers: * or Access-Control-Expose-Headers: * on credentialed requests.
Configure CORS for the header family you use (sentinelHeaders):
| Allow on preflight (request) | Expose on response | |
|---|---|---|
"legacy" | X-Request-Id, X-Visitor-Id, X-PoW-Solution | X-PoW-Challenge, X-PoW-Reason |
"modern" | X-Sentinel-Request-Id, X-Sentinel-Visitor-Id, X-Sentinel-PoW-Solution | X-Sentinel-PoW-Challenge, X-Sentinel-PoW-Reason |
- Allow (request) — headers the client sends: request id, visitor id, and PoW solution on challenge retry.
- Expose (response) — headers the client must read for auto-solve: PoW challenge and reason on
423responses.
If server and clients temporarily disagree on sentinelHeaders, allow and expose both families so either side can still send and read headers.
Example for "modern":
{
// Your real app origin only — not "*"
origin: "https://app.example.com",
credentials: true,
allowedHeaders: [
"Content-Type",
"Authorization",
"X-Sentinel-Request-Id",
"X-Sentinel-Visitor-Id",
"X-Sentinel-PoW-Solution",
],
exposedHeaders: [
"X-Sentinel-PoW-Challenge",
"X-Sentinel-PoW-Reason",
],
}For "legacy", use the legacy names from the table instead of the X-Sentinel-* names.
Restrict CORS to origins you control (see trusted origins). Do not pair origin: "*" with credentialed auth requests.
Proof-of-Work Challenges
When a security check results in a "challenge" action, Sentinel issues a Proof-of-Work (PoW) challenge that must be solved by the client.
How PoW Works
- Server issues a cryptographic challenge
- Client must find a solution that satisfies difficulty requirements
- Solution is computationally expensive but verification is fast
- Prevents automated attacks while allowing legitimate users through
Challenge Difficulty
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
challengeDifficulty: 18, // Default difficulty level
},
}),Higher difficulty = more computation required = slower for attackers.
Client Integration
sentinelClient()
The client plugin handles device fingerprinting and automatic PoW challenge solving.
import { createAuthClient } from "better-auth/client";
import { sentinelClient } from "@better-auth/infra/client";
export const authClient = createAuthClient({
plugins: [
sentinelClient({
autoSolveChallenge: true,
}),
],
});Configuration
| Option | Type | Default | Description |
|---|---|---|---|
identifyUrl | string | BETTER_AUTH_KV_URL or https://kv.better-auth.com | Base URL for the identify endpoint. |
identifyOptions | object | { timeout: 1000, retry: { attempts: 2, baseDelay: 400, maxDelay: 600 } } | Identify HTTP timeout and retry policy. |
sentinelHeaders | "legacy" | "modern" | "legacy" | Header names for visitor id and PoW. Must match server. See Protocol headers. |
autoSolveChallenge | boolean | true | Automatically solve PoW challenges. |
kvTimeout | number | 1000 | Deprecated alias for identifyOptions.timeout. |
onChallengeReceived | (reason: string) => void | — | Called when a PoW challenge is received. |
onChallengeSolved | (solveTimeMs: number) => void | — | Called after a successful solve. |
onChallengeFailed | (error: Error) => void | — | Called if solving fails. |
Browser Fingerprinting
The client automatically includes a visitor ID in requests via the visitor-id header (X-Visitor-Id, or X-Sentinel-Visitor-Id when sentinelHeaders: "modern"). This fingerprint is used for:
- Credential stuffing detection
- Free trial abuse prevention
- Device tracking
PoW Solution Header
When auto-solving is enabled, solved challenges are sent via the PoW solution header (X-PoW-Solution, or X-Sentinel-PoW-Solution when sentinelHeaders: "modern").
Expo and React Native
For Expo and React Native apps, use sentinelNativeClient from @better-auth/infra/native instead of using @better-auth/infra/client.
For React Native apps, install the following peer dependencies:
npm install @react-native-async-storage/async-storage react-native-get-random-values@react-native-async-storage/async-storage is optional. If it is not installed, the client uses a session-only in-memory visitor ID. For production, install it or pass a custom storage (for example a secure store).
Example
import { createAuthClient } from "better-auth/client";
import { dashClient, sentinelNativeClient } from "@better-auth/infra/native";
export const authClient = createAuthClient({
baseURL: "https://your-api.example.com",
plugins: [
dashClient(),
sentinelNativeClient({
autoSolveChallenge: true,
}),
],
});For Expo apps, install the packages below for richer native identity payloads.
Expo: Follow the Expo integration before adding infrastructure plugins.
npm install expo-constants expo-device expo-cryptoExample
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import { dashClient, sentinelNativeClient } from "@better-auth/infra/native";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: "https://your-api.example.com",
plugins: [
expoClient({
scheme: "myapp",
storagePrefix: "myapp",
storage: SecureStore,
}),
dashClient(),
sentinelNativeClient({
autoSolveChallenge: true,
}),
],
});sentinelNativeClient options
| Option | Type | Default | Description |
|---|---|---|---|
identifyUrl | string | BETTER_AUTH_KV_URL, then https://kv.better-auth.com | Identify endpoint base URL. |
identifyOptions | object | { timeout: 1000, retry: { attempts: 2, baseDelay: 400, maxDelay: 600 } } | Identify HTTP timeout and retry policy. |
sentinelHeaders | "legacy" | "modern" | "legacy" | Header names for visitor id and PoW. Must match server. See Protocol headers. |
kvTimeout | number | 1000 | Deprecated alias for identifyOptions.timeout. |
autoSolveChallenge | boolean | true | On 423 with a PoW challenge header, solve and retry once with the solution header. |
onChallengeReceived | (reason: string) => void | — | Called when a PoW challenge is received. |
onChallengeSolved | (solveTimeMs: number) => void | — | Called after a successful solve. |
onChallengeFailed | (error: Error) => void | — | Called if solving fails. |
storage | { getItem, setItem } | Async Storage when installed | Persistent async storage for a stable per-install visitor ID. |
Security Events
Sentinel records security activity as audit events:
| Event Type | Description |
|---|---|
security_check | One event per protected user action. eventData.outcome is passed, challenged, or blocked. Includes evaluatedChecks, triggeredBy, and reason. |
security_signal | Informational detection detail for the Security dashboard. Does not count toward security usage. |
These events are visible in the Security dashboard and included in audit logs.
Complete Example
import { betterAuth } from "better-auth";
import { sentinel } from "@better-auth/infra";
export const auth = betterAuth({
plugins: [
sentinel({
apiKey: process.env.BETTER_AUTH_API_KEY,
security: {
// Core protections
credentialStuffing: {
enabled: true,
thresholds: { challenge: 3, block: 5 },
},
compromisedPassword: {
enabled: true,
action: "block",
},
emailValidation: {
enabled: true,
strictness: "medium",
action: "block",
},
// Location-based
impossibleTravel: {
enabled: true,
action: "challenge",
},
geoBlocking: {
denyList: ["XX"],
action: "block",
},
// Abuse prevention
freeTrialAbuse: {
enabled: true,
maxAccountsPerVisitor: 3,
action: "block",
},
velocity: {
enabled: true,
maxSignupsPerVisitor: 5,
action: "challenge",
},
// Bot protection
botBlocking: { action: "challenge" },
suspiciousIpBlocking: { action: "block" },
// Account monitoring
staleUsers: {
enabled: true,
staleDays: 90,
notifyUser: true,
notifyAdmin: true,
adminEmail: "security@yourapp.com",
},
},
}),
],
});Best Practices
-
Start with logging - Set actions to "log" initially to understand your traffic patterns before blocking.
-
Tune thresholds - Every application is different. Monitor false positives and adjust thresholds accordingly.
-
Use challenges before blocks - Challenges allow legitimate users through while stopping automated attacks.
-
Enable client-side auto-solve - Use
sentinelClient({ autoSolveChallenge: true })in the browser, orsentinelNativeClient({ autoSolveChallenge: true })in Expo / React Native. -
Monitor security events - Regularly review the Security dashboard to identify attack patterns.
-
Set up admin notifications - Enable
notifyAdminfor critical events like stale account reactivations.
Troubleshooting
Missing API Key Warning
[Sentinel] Missing BETTER_AUTH_API_KEY. Security checks may fall back to allow mode.Make sure your API key environment variable is set correctly.
Challenges Not Working
If PoW challenges aren't being solved:
- Verify the correct client plugin:
sentinelClient()for web (@better-auth/infra/client), orsentinelNativeClient()for Expo / React Native (@better-auth/infra/native) - Check that
autoSolveChallengeistrue - Ensure the client can reach the server
High False Positive Rate
If legitimate users are being blocked:
- Increase thresholds
- Change action from "block" to "challenge"
- Review security events to identify patterns
Audit Logs
Track and query authentication events across your application with automatic audit logging.
Email Service
Better Auth Infrastructure provides a managed transactional email service with pre-built templates for common authentication flows. Send verification emails, password resets, invitations, and more without managing email infrastructure.