Security
Most Slingshot defaults are safe. What they don’t know is your threat model. Here’s every security knob worth tuning before you ship to production.
Configuration validation
Section titled “Configuration validation”createAuthPlugin() validates schema-owned configuration strictly. Unknown keys abort startup
instead of being ignored, because silently dropping a misspelled security option can restore a
weaker default without the operator noticing.
When upgrading an existing application, run it once in a non-production environment and resolve
every Unrecognized key startup error. The error includes the full configuration path. Common
invalid aliases include:
| Invalid key | Supported configuration |
|---|---|
passwordPolicy.requireUppercase | passwordPolicy.requireLetter |
passwordPolicy.requireLowercase | passwordPolicy.requireLetter |
passwordPolicy.requireNumber | passwordPolicy.requireDigit |
refreshTokens.ttlSeconds | refreshTokens.accessTokenExpiry / refreshTokenExpiry |
refreshTokens.reuseDetection | Rotation is built in; configure rotationGraceSeconds |
auth.deleteAccount | auth.accountDeletion |
Do not catch and suppress plugin configuration errors during startup. A rejected configuration means the requested security policy was not applied.
JWT signing
Section titled “JWT signing”Every JWT issued by slingshot-auth signs with this secret. All sessions depend on it.
Minimum 32 characters. Slingshot uses the secret as an HMAC-SHA256 key. Anything shorter is rejected at startup.
Never hardcode it. It belongs in a secret manager or environment variable, not in source code.
export default defineApp({ security: { signing: { secret: process.env.SIGNING_SECRET! }, },});Never process.env.SIGNING_SECRET ?? 'fallback' — if the variable is missing, let it fail at
startup rather than silently using a weak key. Populate process.env from your configured
secrets provider (SSM in production, .env in development); see
Secrets for the full provider reference.
Token expiry
Section titled “Token expiry”The default session lifetime is 7 days (604 800 seconds). Set it via the session policy:
createAuthPlugin({ auth: { sessionPolicy: { absoluteTimeout: 86400, // 24 hours idleTimeout: 1800, // revoke after 30 min of inactivity }, },});For short-lived access tokens with a refresh flow:
createAuthPlugin({ auth: { refreshTokens: { accessTokenExpiry: 900, // 15 min access token refreshTokenExpiry: 2_592_000, // 30 day refresh token rotationGraceSeconds: 10, // old token still valid for 10s after rotation }, },});Zero-downtime key rotation
Section titled “Zero-downtime key rotation”Rotating the signing secret invalidates all active sessions — unless you use previousSecrets:
export default defineApp({ security: { signing: { secret: [process.env.SIGNING_SECRET_V2!, process.env.SIGNING_SECRET_V1!], }, },});New tokens sign with V2. Tokens signed with V1 stay valid until they expire. Once your session TTL has elapsed since the rotation, remove previousSecrets.
Issuer and audience
Section titled “Issuer and audience”Set jwt.issuer and jwt.audience so tokens are scoped to your application. Slingshot fails production boot when either is missing and warns during development.
createAuthPlugin({ auth: { jwt: { issuer: 'https://api.example.com', audience: 'https://app.example.com', }, },});Both claims are embedded in every signed JWT and verified on every token check. Without them, a token minted by another service using the same signing secret could be accepted.
Clock tolerance
Section titled “Clock tolerance”Distributed systems have minor clock drift between nodes. Slingshot applies a 60-second clock tolerance by default when verifying JWT exp, nbf, and iat claims.
createAuthPlugin({ auth: { jwt: { clockTolerance: 30, // seconds — set to 0 to disable }, },});Session fingerprint binding
Section titled “Session fingerprint binding”Bind sessions to the client’s network fingerprint so that a stolen JWT cannot be replayed from a different device or location.
export default defineApp({ security: { signing: { secret: process.env.SIGNING_SECRET!, sessionBinding: { fields: ['ip', 'ua'], // hash IP + User-Agent (default) onMismatch: 'unauthenticate', // 'unauthenticate' | 'reject' | 'log-only' }, }, },});onMismatch | Behavior |
|---|---|
'unauthenticate' | Silently treat the request as unauthenticated (default) |
'reject' | Throw a 401 error immediately |
'log-only' | Accept the request but emit a warning log |
The fingerprint is stored on the first authenticated request and checked on every subsequent request, including refresh token exchanges.
security.cors controls the Access-Control-Allow-Origin header. Restrict it to your actual frontend domains.
export default defineApp({ security: { cors: ['https://app.example.com'], },});Rate limiting
Section titled “Rate limiting”Auth endpoints are rate-limited out of the box. The defaults are conservative — tighten them before shipping.
createAuthPlugin({ auth: { rateLimit: { login: { windowMs: 15 * 60 * 1000, max: 10 }, // 10 attempts per 15 min register: { windowMs: 60 * 60 * 1000, max: 5 }, // 5 registrations per hour forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // 5 resets per 15 min mfaVerify: { windowMs: 15 * 60 * 1000, max: 10 }, store: 'redis', // share counters across instances in production }, },});store defaults to 'redis' when Redis is available, 'memory' otherwise. In-memory counters are per-process — with multiple server instances, each gets its own window. Use 'redis' for accurate limits across instances.
Credential stuffing detection
Section titled “Credential stuffing detection”Detects distributed password stuffing attacks — many logins across many accounts from the same IP, or many IPs per account:
createAuthPlugin({ auth: { rateLimit: { credentialStuffing: { maxAccountsPerIp: { count: 5, windowMs: 10 * 60 * 1000 }, maxIpsPerAccount: { count: 3, windowMs: 10 * 60 * 1000 }, onDetected: ({ type, key, count }) => { alerting.send({ type, key, count }); }, }, }, },});When a threshold is crossed, the bus emits security.credential_stuffing.detected with severity 'critical'. Wire a handler via securityEvents:
createAuthPlugin({ securityEvents: { onEvent: event => { if (event.severity === 'critical') { pagerduty.alert(event); } auditLog.write(event); }, },});Rate limiting your own routes
Section titled “Rate limiting your own routes”The framework imposes no rate limits on non-auth routes. Apply Hono middleware directly:
import type { MiddlewareHandler } from 'hono';import { createRouter } from '@lastshotlabs/slingshot';
declare function rateLimiter(options: { windowMs: number; limit: number; standardHeaders?: string;}): MiddlewareHandler;
// or your preferred library
const router = createRouter();
router.use( '/api/expensive-operation', rateLimiter({ windowMs: 60 * 1000, limit: 10, standardHeaders: 'draft-6', }),);CSRF protection
Section titled “CSRF protection”Required when using cookie-based sessions. The auth cookie is HttpOnly — JavaScript can’t read it, but a malicious site can still trigger authenticated requests when same-site cookies alone aren’t sufficient.
Slingshot uses the double-submit cookie pattern: on login, it sets a CSRF token in a non-HttpOnly cookie. Your frontend reads that cookie and echoes it back in the x-csrf-token header on every state-changing request. The server verifies that cookie and header match.
createAuthPlugin({ auth: { csrf: { enabled: true }, },});When CSRF is enabled, POST, PUT, PATCH, and DELETE requests that carry the auth cookie must also include the x-csrf-token header matching the csrf_token cookie. Slingshot also protects public session-establishing auth routes like login, register, refresh, MFA completion, and OAuth code exchange even before an auth cookie exists, so those endpoints require the same double-submit token.
Exempt paths — webhook receivers and OAuth callbacks should bypass CSRF:
createAuthPlugin({ auth: { csrf: { enabled: true, exemptPaths: ['/webhooks/*', '/auth/oauth/*/callback'], }, },});Password policy
Section titled “Password policy”The defaults are minimal: 8 characters, one letter, one digit. Tighten before shipping:
createAuthPlugin({ auth: { passwordPolicy: { minLength: 12, requireLetter: true, requireDigit: true, requireSpecial: true, preventReuse: 5, // remember last 5 hashes }, },});preventReuse stores the last N password hashes and rejects reuse. All built-in adapters (memory, SQLite, Postgres, MongoDB) support it.
Breached password detection
Section titled “Breached password detection”Block passwords from known breach databases via the HaveIBeenPwned k-Anonymity API. Only the first 5 characters of the SHA-1 hash leave your server — the full password never does.
createAuthPlugin({ auth: { breachedPasswordCheck: { block: true, onApiFailure: 'block', // fail-closed (default); use 'allow' for fail-open minBreachCount: 1, }, },});onApiFailure: 'block' (default) is fail-closed: if HIBP is unreachable, the password is rejected. Use 'allow' for fail-open if you prefer availability over safety.
Registration security
Section titled “Registration security”Concealed registration
Section titled “Concealed registration”By default, Slingshot conceals whether an email is already registered. The /auth/register endpoint returns the same response shape regardless of whether the account already exists, preventing user enumeration attacks.
To disable this behavior (not recommended for public-facing apps):
createAuthPlugin({ auth: { concealRegistration: null, // disable — registration will return 409 for existing emails },});Email normalization
Section titled “Email normalization”All auth adapters normalize email addresses before storage and lookup, preventing duplicate accounts via cosmetic variations:
- Case folding —
Alice@Example.com→alice@example.com - Plus-addressing removal (Gmail/Googlemail) —
user+tag@gmail.com→user@gmail.com - Dot removal (Gmail/Googlemail) —
u.s.e.r@gmail.com→user@gmail.com
This is applied automatically on create, findByEmail, findByIdentifier, and findOrCreateByProvider. No configuration needed.
Session security
Section titled “Session security”Session limits and timeouts
Section titled “Session limits and timeouts”createAuthPlugin({ auth: { sessionPolicy: { maxSessions: 5, // evict oldest when exceeded (default: 6) absoluteTimeout: 604_800, // 7 days max session lifetime idleTimeout: 3_600, // revoke after 1 hour of inactivity onPasswordChange: 'revoke_others', // invalidate other sessions after password change }, },});onPasswordChange | Behavior |
|---|---|
'revoke_others' (default) | Keeps the current session, revokes all others |
'revoke_all_and_reissue' | Revokes everything, issues a fresh session token |
'none' | Does nothing — not recommended |
Revoking sessions
Section titled “Revoking sessions”Users can view and revoke their own sessions:
# List sessionsGET /auth/sessions
# Revoke a specific sessionDELETE /auth/sessions/:sessionId
# Revoke all other sessions (log out everywhere)DELETE /auth/sessionsThese mount automatically via slingshot-auth. No additional configuration needed.
Cookie vs. Bearer trade-offs
Section titled “Cookie vs. Bearer trade-offs”| Cookie | Bearer | |
|---|---|---|
| Storage | HttpOnly cookie — browser handles it | Client stores in memory or localStorage |
| XSS exposure | Not readable by JS | Readable if stored in localStorage |
| CSRF risk | Yes — requires CSRF protection | No |
| Works with SPAs | Yes, requires CORS + CSRF config | Yes, straightforward |
| Mobile/API clients | Awkward | Natural |
userAuth accepts the JWT in either Authorization: Bearer <token> or the x-user-token header. For non-browser clients (mobile, other servers, CLI tools), Bearer is simpler. For browser SPAs, both work — cookies offload token storage to the browser.
Secrets management
Section titled “Secrets management”Never commit secrets to source control — not in config files, not in comments, not in test fixtures. Use real secrets in tests, or use obviously fake values like test-signing-secret-32-chars-ok! that can’t be mistaken for production keys.
Prefer a managed secrets provider (SSM, file-backed) over raw env vars for sensitive values. Raw env vars can appear in process listings and logs. Configure secrets in app.config.ts so the framework hydrates process.env from SSM at boot, and the secret never sits in your deploy environment directly.
Validate presence at startup. Run slingshot secrets check --stage prod before every deploy:
slingshot secrets check --stage prod# ✓ DATABASE_URL present in ssm:/myapp/prod/# ✗ GOOGLE_CLIENT_ID missing — required by slingshot-oauthThe Secrets guide covers providers in full.
Input validation
Section titled “Input validation”All routes defined via router.openapi() / createRoute() validate request bodies, params, and query strings via Zod automatically. Validation errors return 422 before your handler runs.
import { createRoute, createRouter } from '@lastshotlabs/slingshot-core';
const router = createRouter();const paymentRequestSchema = { type: 'object', properties: { amount: { type: 'integer', minimum: 1, maximum: 999_999_99 }, // cents currency: { type: 'string', enum: ['usd', 'eur', 'gbp'] }, description: { type: 'string', maxLength: 500 }, }, required: ['amount', 'currency', 'description'],} as const;const paymentResponseSchema = { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string', enum: ['created'] }, }, required: ['id', 'status'],} as const;
router.openapi( createRoute({ method: 'post', path: '/payments', request: { body: { required: true, content: { 'application/json': { schema: paymentRequestSchema as never, }, }, }, }, responses: { 200: { description: 'Payment created', content: { 'application/json': { schema: paymentResponseSchema as never, }, }, }, }, }), async c => { const body = (await c.req.json()) as { amount: number; currency: 'usd' | 'eur' | 'gbp'; description: string; }; return c.json({ id: `pay_${body.currency}`, status: 'created' as const, } as never); },);Don’t skip schema definitions. z.any() or z.unknown() passes everything through — intentionally or not.
Validate params and query strings too. Body validation is easy to remember; these aren’t:
createRoute({ method: 'get', path: '/users/:id', request: { params: z.object({ id: z.string().uuid() }), // validates format query: z.object({ page: z.coerce.number().min(1).optional() }), // coerces string → number }, // ...});Security headers
Section titled “Security headers”Slingshot doesn’t set security headers (Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, etc.). Set them at the reverse proxy or CDN layer.
When running behind nginx, Caddy, or an AWS ALB, configure headers there. Apps deployed via slingshot-infra include a baseline set in the generated Caddy and nginx configs under the # --- section:security-headers --- block, which you can override.
Enforce MFA for all users:
createAuthPlugin({ auth: { mfa: { issuer: 'Acme', required: true, // block non-auth routes until MFA setup complete emailOtp: { codeLength: 6 }, }, },});required: true redirects users without MFA configured to the setup flow. Only MFA routes and auth routes are accessible until setup completes.
For high-security operations, use step-up auth:
// On a route that requires step-up:import type { MiddlewareHandler } from 'hono';import { createRouter } from '@lastshotlabs/slingshot';import { createAuthPlugin, userAuth } from '@lastshotlabs/slingshot-auth';
declare const requireStepUp: (opts?: { maxAge?: number }) => MiddlewareHandler;
const router = createRouter();
createAuthPlugin({ auth: { stepUp: { maxAge: 300 }, // MFA must have been verified within the last 5 min },});
router.use('/admin/export', userAuth, requireStepUp({ maxAge: 300 }));Security event auditing
Section titled “Security event auditing”Every auth event emits on the internal event bus. Wire an onEvent handler to forward them to your audit log or SIEM:
createAuthPlugin({ securityEvents: { onEvent: event => { // event.eventType, event.severity, event.userId, event.ip, event.timestamp if (event.severity === 'critical') { alerting.page(event); } auditLog.append(event); }, onEventError: err => { logger.error('Security event handler failed', err); }, exclude: ['security.auth.login.success'], // drop noisy low-severity events if needed },});Security events never reach browser clients via SSE — the security.* namespace is blocked at the bus level regardless of configuration.
Production checklist
Section titled “Production checklist”Secrets
-
signing.secretis at least 32 characters and stored in SSM or equivalent - No secrets in source control, config files, or
.envfiles committed to the repo -
slingshot secrets check --stage prodpasses clean
JWT
-
jwt.issuerandjwt.audienceare configured - Token expiry is appropriate for your threat model (
absoluteTimeout,idleTimeout) - Refresh token rotation is configured if using short-lived access tokens
-
previousSecretsrotation plan is documented for when the signing key changes
CORS
-
security.corslists specific origins — not'*' - Origins are HTTPS in production, not
http://
Auth hardening
-
passwordPolicy.minLengthis at least 12 -
passwordPolicy.requireSpecialis enabled - Rate limits are tightened for
login,register, andforgotPassword - Rate limit
storeis set to'redis'in multi-instance deployments - Credential stuffing detection is enabled with
maxAccountsPerIp - CSRF protection is enabled if using cookie-based sessions
-
sessionPolicy.onPasswordChangeis'revoke_others'or'revoke_all_and_reissue'
MFA (if applicable)
-
mfa.required: truefor admin users or all users - Step-up auth is applied to high-value operations
Input validation
- Every
router.openapi()call has a schema with meaningful constraints — noz.any() - Params and query strings are validated, not just bodies
Session hardening
- Session fingerprint binding is enabled (
signing.sessionBinding) in production -
authCookie.domainis not set to an overly broad value (e.g.,.com)
Infrastructure
- HTTPS is enforced at the load balancer or reverse proxy
- Security headers are set at the proxy layer
-
trustProxyis configured if reading client IPs behind a load balancer
Observability
-
securityEvents.onEventis wired to an audit log or SIEM - Critical severity events (
credential_stuffing.detected,account.locked) trigger alerts
See also
Section titled “See also”- Secrets — full secrets provider reference with SSM,
.env, and environment variables - Auth Setup — OAuth, MFA, passkeys, and email verification
- Horizontal Scaling — shared sessions and rate limit stores across instances
- Deployment — how
slingshot-inframanages secrets and deploys