fix(api): add tiered rate limiting to prevent polling lockouts (#460)

* fix(api): add tiered rate limiting to prevent polling lockouts

Replace the single global rate limiter (100 req/min/IP) with a tiered
system that separates high-frequency polling endpoints from standard
API traffic:

- Polling tier (300/min): /stats, /system/stats, /stacks/statuses,
  /metrics/historical, /health, /meta, /auth/status, /auth/sso/providers,
  /license. Exempt from the global limiter but governed by their own
  safety net to prevent resource exhaustion.
- Standard tier (200/min): All other endpoints, raised from 100.
- Webhook tier (500/min): POST /webhooks/:id/trigger, dedicated limiter
  for CI/CD platforms sharing datacenter IPs.
- Auth tier: Unchanged (5-10 attempts / 15 min).

Enterprise adaptations:
- Authenticated requests keyed by user session (JWT sub/username) instead
  of IP, preventing shared NAT/VPN environments from pooling budgets.
- Internal node-to-node traffic (node_proxy tokens) bypasses all rate
  limiters entirely.

Includes comprehensive stress tests (21 cases) validating tier
separation, node proxy bypass, and per-user keying.

* chore(deps): bump axios to 1.15.0 to fix SSRF vulnerability

Addresses GHSA-3p68-rc4w-qgx5 (NO_PROXY hostname normalization bypass).
This commit is contained in:
Anso
2026-04-09 17:56:58 -04:00
committed by GitHub
parent 7b6cd79e92
commit 8e1b9826cf
9 changed files with 452 additions and 21 deletions
+9 -2
View File
@@ -19,8 +19,15 @@ NODE_ENV=production
# Frontend URL for CORS in production (leave empty for same-origin setups)
FRONTEND_URL=
# Global API rate limit (requests per minute per IP, production only)
API_RATE_LIMIT=100
# Global API rate limit (requests per minute per user session, production only)
# Authenticated requests are keyed by user ID; unauthenticated by IP.
# Internal node-to-node traffic (node_proxy tokens) bypasses this limit entirely.
API_RATE_LIMIT=200
# Polling endpoint rate limit (requests per minute, production only)
# Applies to dashboard/status polling endpoints that are exempt from the global limit.
# Increase for environments with many concurrent browser sessions behind shared NAT.
API_POLLING_RATE_LIMIT=300
# ─── SSO / LDAP Configuration (Admiral) ───────────────────────────
+12
View File
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
* **api:** add tiered rate limiting to prevent dashboard polling lockouts. High-frequency
polling endpoints (stats, system stats, stack statuses) are now exempt from the global
rate limiter and governed by a separate 300 req/min safety net. The global limit is
raised from 100 to 200 req/min. Authenticated requests are now keyed by user session
instead of IP address, preventing shared NAT/VPN environments from pooling rate limit
budgets. Internal node-to-node traffic (node_proxy tokens) bypasses all rate limiters.
Webhook triggers get a dedicated 500 req/min limit for CI/CD integrations.
## [0.42.3](https://github.com/AnsoCode/Sencho/compare/v0.42.2...v0.42.3) (2026-04-09)
+4 -4
View File
@@ -17,7 +17,7 @@
"@types/http-proxy": "^1.17.17",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"axios": "^1.13.6",
"axios": "^1.15.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"composerize": "^1.7.5",
@@ -2763,9 +2763,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
+1 -1
View File
@@ -43,7 +43,7 @@
"@types/http-proxy": "^1.17.17",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"axios": "^1.13.6",
"axios": "^1.15.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"composerize": "^1.7.5",
+282
View File
@@ -0,0 +1,282 @@
/**
* Stress tests for the tiered rate limiting system.
*
* Validates that:
* - Polling endpoints are exempt from the global limiter but have their own ceiling
* - Standard endpoints are governed by the global limiter
* - Webhook triggers have a dedicated limiter
* - Node proxy tokens bypass all rate limiters
* - Authenticated requests are keyed by user session, not IP
*
* These tests run against the real Express app with in-memory SQLite.
* Rate limits in development mode are 10x production values (1000/3000/5000),
* so we test header presence and tier separation rather than hitting ceilings.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
// Log in to get an auth cookie for authenticated tests
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
const setCookie = loginRes.headers['set-cookie'];
authCookie = Array.isArray(setCookie) ? setCookie[0] : setCookie;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
// ── Tier 0/1: Polling endpoints ──────────────────────────────────────────────
describe('Tier 0/1: Polling endpoints', () => {
const pollingPaths = [
'/api/health',
'/api/meta',
'/api/stats',
'/api/system/stats',
'/api/stacks/statuses',
'/api/metrics/historical',
'/api/auth/status',
'/api/auth/sso/providers',
'/api/license',
];
it.each(pollingPaths)('%s returns polling-tier rate limit headers', async (path) => {
const res = await request(app).get(path);
const limit = parseInt(res.headers['ratelimit-limit'], 10);
// Dev mode polling limit is 3000 (production would be 300)
expect(limit).toBe(3000);
});
it('polling endpoints do NOT count against the global limiter', async () => {
// Snapshot the global limiter's remaining count before polling
const before = await request(app).get('/api/stacks');
const remainBefore = parseInt(before.headers['ratelimit-remaining'], 10);
// Hit 10 polling endpoints (none should decrement the global counter)
await Promise.all(Array.from({ length: 10 }, () =>
request(app).get('/api/health')
));
// Check that the global limiter's remaining decreased by exactly 1
// (the GET /api/stacks below, not the 10 polling requests)
const after = await request(app).get('/api/stacks');
const remainAfter = parseInt(after.headers['ratelimit-remaining'], 10);
expect(remainAfter).toBe(remainBefore - 1);
});
it('rapid polling does not trigger global rate limit', async () => {
// Snapshot the global limiter budget before polling
const before = await request(app).get('/api/stacks');
const remainBefore = parseInt(before.headers['ratelimit-remaining'], 10);
// Fire 20 rapid polling requests
const requests = Array.from({ length: 20 }, () =>
request(app).get('/api/stats')
);
const results = await Promise.all(requests);
// All should succeed (no 429)
for (const res of results) {
expect(res.status).not.toBe(429);
}
// Standard endpoint budget should only decrease by 1 (this request),
// not by 21 (the 20 polling requests should not count)
const after = await request(app).get('/api/stacks');
const remainAfter = parseInt(after.headers['ratelimit-remaining'], 10);
expect(remainAfter).toBe(remainBefore - 1);
});
});
// ── Tier 2: Standard endpoints ───────────────────────────────────────────────
describe('Tier 2: Standard endpoints', () => {
it('standard endpoints return global-tier rate limit headers', async () => {
const res = await request(app).get('/api/stacks');
const limit = parseInt(res.headers['ratelimit-limit'], 10);
// Dev mode global limit is 1000 (production would be 200)
expect(limit).toBe(1000);
});
it('standard endpoints do NOT get polling-tier headers', async () => {
const res = await request(app).get('/api/containers');
const limit = parseInt(res.headers['ratelimit-limit'], 10);
// Should be 1000 (global), not 3000 (polling)
expect(limit).not.toBe(3000);
expect(limit).toBe(1000);
});
});
// ── Node proxy bypass ────────────────────────────────────────────────────────
describe('Node proxy bypass', () => {
it('requests with valid node_proxy token bypass all rate limiters', async () => {
const nodeToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1h' });
const res = await request(app)
.get('/api/stats')
.set('Authorization', `Bearer ${nodeToken}`);
// Node proxy requests should NOT receive rate limit headers from either limiter
// (both skip functions return true for node_proxy tokens)
// When skipped, express-rate-limit does not set headers
expect(res.headers['ratelimit-limit']).toBeUndefined();
});
it('requests with invalid Bearer token still get rate limited', async () => {
const res = await request(app)
.get('/api/stats')
.set('Authorization', 'Bearer invalid-token-here');
// Should still get rate limit headers (polling tier for /stats)
const limit = parseInt(res.headers['ratelimit-limit'], 10);
expect(limit).toBe(3000);
});
it('requests with non-node_proxy Bearer token still get rate limited', async () => {
// A regular user JWT (not node_proxy scope) should still be rate limited
const userToken = jwt.sign(
{ username: 'testuser', role: 'admin' },
TEST_JWT_SECRET,
{ expiresIn: '1h' }
);
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${userToken}`);
const limit = parseInt(res.headers['ratelimit-limit'], 10);
expect(limit).toBe(1000);
});
});
// ── Hybrid key generator (per-user keying) ───────────────────────────────────
describe('Hybrid key generator', () => {
it('authenticated requests are keyed by user, not IP', async () => {
// Make requests with auth cookie
const res1 = await request(app)
.get('/api/stacks')
.set('Cookie', authCookie);
// Make a request without auth (keyed by IP)
const res2 = await request(app).get('/api/stacks');
// Both should succeed, and their remaining counts should be independent
// (different rate limit buckets: user:testadmin vs IP)
const remaining1 = parseInt(res1.headers['ratelimit-remaining'], 10);
const remaining2 = parseInt(res2.headers['ratelimit-remaining'], 10);
// Each should have its own budget (999 for this test since each made 1 request)
expect(remaining1).toBeGreaterThanOrEqual(990);
expect(remaining2).toBeGreaterThanOrEqual(990);
});
it('two different users get independent rate limit budgets', async () => {
// User A (via cookie)
const resA = await request(app)
.get('/api/stacks')
.set('Cookie', authCookie);
// User B (via Bearer with different username)
const userBToken = jwt.sign(
{ username: 'otheruser', role: 'viewer' },
TEST_JWT_SECRET,
{ expiresIn: '1h' }
);
const resB = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${userBToken}`);
// Both should have independent remaining counts
const remainA = parseInt(resA.headers['ratelimit-remaining'], 10);
const remainB = parseInt(resB.headers['ratelimit-remaining'], 10);
// Each user's budget should be near-full
expect(remainA).toBeGreaterThanOrEqual(990);
expect(remainB).toBeGreaterThanOrEqual(990);
});
});
// ── Webhook trigger tier ─────────────────────────────────────────────────────
describe('Tier W: Webhook trigger', () => {
it('webhook trigger route is exempt from the global limiter', async () => {
// We can't easily test the webhook-specific limiter without creating a webhook,
// but we can verify the global limiter skip logic by checking that rapid
// POST requests to a webhook-trigger-shaped path don't decrement the global counter.
// Hit a standard endpoint first to establish a baseline
const baseline = await request(app).get('/api/stacks');
const baselineRemaining = parseInt(baseline.headers['ratelimit-remaining'], 10);
// POST to webhook trigger (will 404 since no webhook exists, but the rate
// limiter skip runs before the route handler)
await request(app).post('/api/webhooks/999/trigger').send({});
// Check that the global limiter's remaining hasn't decreased
const after = await request(app).get('/api/stacks');
const afterRemaining = parseInt(after.headers['ratelimit-remaining'], 10);
// Should have decreased by exactly 1 (the GET /api/stacks request itself)
expect(afterRemaining).toBe(baselineRemaining - 1);
});
});
// ── Tier separation stress test ──────────────────────────────────────────────
describe('Tier separation under load', () => {
it('50 polling requests do not affect standard endpoint budget', async () => {
// Blast 50 polling requests in parallel
const pollingRequests = Array.from({ length: 50 }, () =>
request(app).get('/api/health')
);
await Promise.all(pollingRequests);
// Standard endpoint should still have a nearly full budget
const stdRes = await request(app).get('/api/stacks');
const remaining = parseInt(stdRes.headers['ratelimit-remaining'], 10);
// Should have lost very few from the standard budget (only from other tests)
expect(remaining).toBeGreaterThanOrEqual(980);
});
it('mixed polling and standard requests maintain independent counters', async () => {
// Interleave 10 polling and 10 standard requests
const mixed = [];
for (let i = 0; i < 10; i++) {
mixed.push(request(app).get('/api/stats')); // polling
mixed.push(request(app).get('/api/containers')); // standard
}
const results = await Promise.all(mixed);
// No 429s should occur (we're well within dev limits)
for (const res of results) {
expect(res.status).not.toBe(429);
}
// Check that polling results have polling-tier headers
for (let i = 0; i < results.length; i += 2) {
const limit = parseInt(results[i].headers['ratelimit-limit'], 10);
expect(limit).toBe(3000);
}
// Check that standard results have global-tier headers
for (let i = 1; i < results.length; i += 2) {
const limit = parseInt(results[i].headers['ratelimit-limit'], 10);
expect(limit).toBe(1000);
}
});
});
+125 -11
View File
@@ -1,7 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import helmet from 'helmet';
import WebSocket, { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
@@ -138,22 +138,137 @@ app.use(cors({
credentials: true,
}));
// Global API rate limiter — caps total requests per IP across all /api/ routes.
// Auth-specific limiters (authRateLimiter, ssoRateLimiter) apply additional,
// stricter limits on their respective routes and stack independently.
const globalApiLimiter = rateLimit({
// Cookie parser must run before rate limiters so the hybrid key generator
// can read req.cookies for per-user rate limit bucketing.
app.use(cookieParser());
// ── Rate Limiting ─────────────────────────────────────────────────────────────
//
// Tiered rate limiting to prevent UX lockouts while maintaining security:
// Tier 0/1 (Polling): High-frequency GET endpoints exempt from global limit,
// with a 300/min safety net to prevent resource exhaustion.
// Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs).
// Tier 2 (Standard): All other endpoints at 200/min (raised from 100).
// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min).
//
// Enterprise adaptations:
// - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters.
// - Authenticated requests are keyed by user ID (not IP) to prevent shared
// NAT/VPN environments from pooling rate limit budgets.
/** Read-only GET endpoints polled at high frequency by the dashboard/fleet UI. */
const POLLING_EXEMPT_PATHS = new Set([
'/meta', '/health', '/stats', '/system/stats',
'/stacks/statuses', '/metrics/historical',
'/auth/status', '/auth/sso/providers', '/license',
]);
const WEBHOOK_TRIGGER_RE = /^\/webhooks\/\d+\/trigger$/;
/**
* Returns true if the request bears a node_proxy Bearer token.
* Uses jwt.decode() (no signature verification) to avoid crypto overhead on the
* hot path; authMiddleware performs full verification downstream. Worst case for
* a forged token: it skips the rate limiter but is still rejected by auth.
* Result is memoized on the request object so the two sequential limiters
* don't repeat the work.
*/
function isNodeProxyRequest(req: Request): boolean {
const cached = (req as any)._isNodeProxy;
if (cached !== undefined) return cached;
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
(req as any)._isNodeProxy = false;
return false;
}
try {
const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null;
const result = decoded?.scope === 'node_proxy';
(req as any)._isNodeProxy = result;
return result;
} catch {
(req as any)._isNodeProxy = false;
return false;
}
}
/**
* Hybrid rate limit key: uses the JWT username/sub claim for authenticated
* requests (per-user budgets) and falls back to IP for unauthenticated ones.
* Uses jwt.decode() (no verification) to avoid double-verification cost;
* authMiddleware handles signature checks downstream.
*/
function rateLimitKeyGenerator(req: Request): string {
const cookie = req.cookies?.[COOKIE_NAME];
if (cookie) {
try {
const decoded = jwt.decode(cookie) as { username?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
} catch { /* fall through to IP */ }
}
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
try {
const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
if (decoded?.sub) return `user:${decoded.sub}`;
} catch { /* fall through to IP */ }
}
return ipKeyGenerator(req.ip || 'unknown');
}
/** Shared config for all rate limiters (1-minute window, standard headers). */
const rateLimitBase = {
windowMs: 60 * 1000,
max: process.env.NODE_ENV === 'production'
? parseInt(process.env.API_RATE_LIMIT || '100', 10)
: 1000,
standardHeaders: true,
legacyHeaders: false,
} as const;
// Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook
// triggers (Tier W), and internal node-to-node traffic (node_proxy).
const globalApiLimiter = rateLimit({
...rateLimitBase,
max: process.env.NODE_ENV === 'production'
? parseInt(process.env.API_RATE_LIMIT || '200', 10)
: 1000,
keyGenerator: rateLimitKeyGenerator,
message: { error: 'Too many requests. Please try again shortly.' },
skip: (req: Request) => req.path === '/meta' || req.path === '/health',
skip: (req: Request) => {
if (req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)) return true;
if (req.method === 'POST' && WEBHOOK_TRIGGER_RE.test(req.path)) return true;
if (isNodeProxyRequest(req)) return true;
return false;
},
});
app.use('/api/', globalApiLimiter);
// Tier 0/1: Polling safety net. Applies only to polling-exempt endpoints to
// prevent resource exhaustion from runaway or malicious polling.
const pollingLimiter = rateLimit({
...rateLimitBase,
max: process.env.NODE_ENV === 'production'
? parseInt(process.env.API_POLLING_RATE_LIMIT || '300', 10)
: 3000,
keyGenerator: rateLimitKeyGenerator,
message: { error: 'Too many polling requests. Please try again shortly.' },
skip: (req: Request) => {
if (isNodeProxyRequest(req)) return true;
return !(req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path));
},
});
app.use('/api/', pollingLimiter);
// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler.
// CI/CD platforms (GitHub Actions, GitLab runners) often share datacenter IPs,
// so a higher ceiling prevents dropped deployments during burst activity.
const webhookTriggerLimiter = rateLimit({
...rateLimitBase,
max: process.env.NODE_ENV === 'production' ? 500 : 5000,
message: { error: 'Too many webhook triggers. Please try again shortly.' },
});
// JSON body parser that also captures the raw bytes for HMAC verification.
const jsonParser = express.json({
verify: (req, _res, buf) => {
@@ -191,7 +306,6 @@ app.use((req: Request, res: Response, next: NextFunction): void => {
}
jsonParser(req, res, next);
});
app.use(cookieParser());
// Node Context Middleware
const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) => {
@@ -2190,7 +2304,7 @@ app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: R
});
// Webhook trigger - public endpoint, authenticated via HMAC signature
app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promise<void> => {
app.post('/api/webhooks/:id/trigger', webhookTriggerLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
+16 -1
View File
@@ -88,7 +88,20 @@ This applies to all stack endpoints: read, write, deploy, down, restart, stop, s
## Rate limiting
All API endpoints are rate-limited per IP address. Authentication endpoints have stricter limits to prevent brute-force attacks. When exceeded, the server responds with `429 Too Many Requests`:
Sencho uses a tiered rate limiting system that balances security with usability:
| Tier | Limit | Applies to | Keyed by |
|------|-------|------------|----------|
| **Polling** | 300/min | Dashboard status endpoints (`/stats`, `/system/stats`, `/stacks/statuses`, etc.) | User session |
| **Standard** | 200/min | All other API endpoints | User session |
| **Webhooks** | 500/min | `POST /webhooks/:id/trigger` | IP address |
| **Auth** | 5-10/15min | Login, setup, SSO endpoints | IP address |
Authenticated requests (browser sessions and API tokens) are rate-limited per user, not per IP. This prevents teams behind shared NAT or VPN from pooling rate limit budgets. Unauthenticated requests fall back to IP-based limiting.
Internal node-to-node traffic (requests authenticated with node proxy tokens) bypasses rate limiting entirely.
When exceeded, the server responds with `429 Too Many Requests`:
```json
{
@@ -98,6 +111,8 @@ All API endpoints are rate-limited per IP address. Authentication endpoints have
Standard rate limit headers (`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`) are included in all API responses, so clients can self-throttle accordingly.
The default limits can be tuned via environment variables (`API_RATE_LIMIT`, `API_POLLING_RATE_LIMIT`). See [Self-Hosting](/operations/self-hosting) for details.
## License tier requirements
Some endpoints are gated by license tier:
+2 -1
View File
@@ -89,7 +89,8 @@ Quick reference for all environment variables. See [Configuration](/getting-star
| `COMPOSE_DIR` | `/app/compose` | Path to compose project files (1:1 rule applies) |
| `DATA_DIR` | `/app/data` | Persistent data directory |
| `FRONTEND_URL` | *(empty)* | Frontend origin for CORS; leave empty for same-origin |
| `API_RATE_LIMIT` | `100` | Maximum API requests per minute per IP (production only) |
| `API_RATE_LIMIT` | `200` | Maximum API requests per minute per user session (production only) |
| `API_POLLING_RATE_LIMIT` | `300` | Rate limit for dashboard polling endpoints (production only) |
| `NODE_ENV` | `production` | Set automatically in the Docker image |
SSO variables are documented separately in the [SSO Quickstart](/getting-started/sso-quickstart).
+1 -1
View File
@@ -13,7 +13,7 @@ A comprehensive security audit was performed before the Sencho paid tiers launch
- **Path traversal prevention** in `env_file` resolution, preventing reads outside the compose directory
- **Stack name validation** enforced on all routes, rejecting names with special characters or path components
- **Global API rate limiting** to protect against brute-force and abuse
- **Tiered API rate limiting** with per-user session keying to protect against brute-force and abuse
### Authentication and secrets