SecretHound supports two authentication methods:
- JWT (JSON Web Token) - For human users accessing the API via web/mobile apps
- API Keys - For machine-to-machine authentication (scanners, CI/CD integrations)
Users must register and login to receive a JWT bearer token. API keys can be created by authenticated users for programmatic access.
Endpoint: POST /api/auth/register
Request:
{
"email": "user@example.com",
"password": "SecurePassword123",
"name": "John Doe"
}Response (200 OK):
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"email": "user@example.com",
"name": "John Doe",
"role": "User"
}What happens:
- Password is hashed using
Microsoft.AspNetCore.Identity.PasswordHasher<User>(PBKDF2 with HMAC-SHA256, 10,000 iterations by default) - User record is created in the database with hashed password
- JWT token is generated and returned
- User can immediately use the token for authenticated requests
Endpoint: POST /api/auth/login
Request:
{
"email": "user@example.com",
"password": "SecurePassword123"
}Response (200 OK):
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"email": "user@example.com",
"name": "John Doe",
"role": "User"
}What happens:
- User is fetched from database by email
- Password is verified using
PasswordHasher.VerifyHashedPassword() - If valid, JWT token is generated and returned
- Returns 401 Unauthorized if credentials are invalid
Example: POST /api/projects (requires authentication)
Request:
curl -X POST https://localhost:5001/api/projects \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{"name": "My Project"}'What happens:
- JWT middleware validates the token signature using the secret key from
appsettings.json - Token claims are extracted and user identity is established
- If valid, request proceeds to controller
- If invalid/missing, returns 401 Unauthorized
Each JWT token contains the following claims:
| Claim | Type | Description | Example |
|---|---|---|---|
sub |
Standard | User ID (GUID) | "3fa85f64-5717-4562-b3fc-2c963f66afa6" |
email |
Standard | User email address | "user@example.com" |
name |
Custom | User display name | "John Doe" |
role |
Custom | User role (User/Admin) | "User" or "Admin" |
jti |
Standard | Unique token ID | "a1b2c3d4-..." |
iss |
Standard | Token issuer | "SecretHound" |
aud |
Standard | Token audience | "SecretHoundClient" |
exp |
Standard | Expiration timestamp | Unix timestamp |
- Access Token: 15 minutes (configurable via
Jwt:AccessTokenLifetimeMinutesinappsettings.json) - No refresh tokens in current implementation (future enhancement)
{
"sub": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "user@example.com",
"name": "John Doe",
"role": "User",
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"iss": "SecretHound",
"aud": "SecretHoundClient",
"exp": 1735001234
}- Library:
Microsoft.AspNetCore.Identity.PasswordHasher<User> - Algorithm: PBKDF2 with HMAC-SHA256
- Iterations: 10,000 (default, configurable)
- Salt: Unique per user, automatically generated
- Output: Base64-encoded hash stored in
User.PasswordHashfield
Current validation (enforced via RegisterRequest DTO):
- Minimum length: 6 characters
- Maximum length: 100 characters
Recommendation for production:
- Increase minimum to 12+ characters
- Require uppercase, lowercase, digit, and special character
- Implement password complexity validation
| Endpoint | Method | Protected | Required Role |
|---|---|---|---|
/api/auth/register |
POST | ❌ No | - |
/api/auth/login |
POST | ❌ No | - |
/api/projects |
GET | ❌ No | - |
/api/projects/{id} |
GET | ❌ No | - |
/api/projects |
POST | ✅ Yes | Any authenticated user |
To protect an endpoint, add the [Authorize] attribute:
[HttpGet]
[Authorize] // Requires any authenticated user
public async Task<ActionResult<IEnumerable<ProjectDto>>> GetProjectsAsync()
{
// ...
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")] // Requires Admin role
public async Task<ActionResult> DeleteProjectAsync(Guid id)
{
// ...
}{
"Jwt": {
"Issuer": "SecretHound",
"Audience": "SecretHoundClient",
"AccessTokenLifetimeMinutes": 15,
"RefreshTokenLifetimeDays": 7,
"Secret": "super-secret-jwt-key-change-me"
}
}Before deploying to production:
-
Change the JWT Secret:
- Generate a strong random secret (minimum 32 characters)
- Use environment variables or Azure Key Vault instead of
appsettings.json
# Example: Generate a secure secret openssl rand -base64 64 -
Use Environment Variables:
export Jwt__Secret="your-production-secret-here"
-
Enable HTTPS Only:
- Tokens should NEVER be transmitted over HTTP
- Enforce HTTPS in production
-
Implement Rate Limiting:
- Protect
/api/auth/loginand/api/auth/registerfrom brute force attacks - Consider using
AspNetCoreRateLimitNuGet package
- Protect
-
Add Refresh Tokens:
- Current implementation only uses short-lived access tokens
- Implement refresh token rotation for better UX
- Navigate to
https://localhost:5001/swagger - Click the "Authorize" button (top right)
- Enter token in format:
Bearer <your-token> - Click "Authorize"
- All subsequent requests will include the token
# 1. Register
TOKEN=$(curl -s -X POST https://localhost:5001/api/auth/register \
-H "Content-Type: application/json" \
-k \
-d '{
"email": "test@example.com",
"password": "TestPassword123",
"name": "Test User"
}' | jq -r '.token')
# 2. Use token to create project
curl -X POST https://localhost:5001/api/projects \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-k \
-d '{"name": "My Project"}'
# 3. Try without token (should return 401)
curl -X POST https://localhost:5001/api/projects \
-H "Content-Type: application/json" \
-k \
-d '{"name": "My Project"}'Defined in SecretHound.Domain.Enums.Role:
- User (default for new registrations)
- Admin
[Authorize(Roles = "Admin")]
public async Task<ActionResult> AdminOnlyEndpoint()
{
// Only users with Admin role can access
}
[Authorize(Roles = "User,Admin")]
public async Task<ActionResult> UserOrAdminEndpoint()
{
// Users with either User or Admin role can access
}public async Task<ActionResult> SomeEndpoint()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var email = User.FindFirst(ClaimTypes.Email)?.Value;
var role = User.FindFirst(ClaimTypes.Role)?.Value;
// Use claims for business logic
}- Password hashing with industry-standard algorithm (PBKDF2)
- JWT signature validation
- Token expiration enforcement
- HTTPS redirection
- Input validation on auth endpoints
- Move JWT secret to environment variables/Key Vault
- Implement refresh token rotation
- Add rate limiting on auth endpoints
- Implement account lockout after failed login attempts
- Add email verification for new registrations
- Implement password reset flow
- Add audit logging for authentication events
- Implement CORS restrictions (currently allows all origins)
- Add multi-factor authentication (MFA)
- Implement token revocation/blacklisting
API keys provide a secure way for automated systems (scanners, CI/CD pipelines, integrations) to authenticate with the SecretHound API without requiring user credentials.
Key Features:
- Keys are hashed using SHA-256 before storage (raw key never stored)
- Only the key prefix is visible after creation
- Keys are associated with a user account for authorization
- Last used timestamp tracking
- Can be activated/deactivated without deletion
Endpoint: POST /api/apikeys
Authentication: Requires JWT Bearer token
Request:
curl -X POST https://localhost:5001/api/apikeys \
-H "Authorization: Bearer <your-jwt-token>" \
-H "Content-Type: application/json" \
-d '{"label": "CI/CD Scanner"}'Response (200 OK):
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"apiKey": "sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"keyPrefix": "sk_a1b2c3d4e5",
"label": "CI/CD Scanner",
"createdAt": "2025-12-23T19:00:00Z",
"warning": "This is the only time you will see this API key. Store it securely."
}- The
apiKeyfield contains the raw API key and is only shown once - Store it immediately in a secure location (password manager, secrets vault)
- The key cannot be retrieved again after this response
- Only the
keyPrefix(first 12 characters) is stored and shown in listings
Endpoint: GET /api/apikeys
Authentication: Requires JWT Bearer token
Request:
curl -X GET https://localhost:5001/api/apikeys \
-H "Authorization: Bearer <your-jwt-token>"Response (200 OK):
[
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"keyPrefix": "sk_a1b2c3d4e5",
"label": "CI/CD Scanner",
"createdAt": "2025-12-23T19:00:00Z",
"lastUsedAt": "2025-12-23T20:15:00Z",
"isActive": true
},
{
"id": "7c8d9e0f-1234-5678-90ab-cdef12345678",
"keyPrefix": "sk_x9y8z7w6v5",
"label": "Development Scanner",
"createdAt": "2025-12-20T10:00:00Z",
"lastUsedAt": null,
"isActive": true
}
]Note: Raw API keys are never returned in list responses, only the prefix.
Header: X-API-Key: <your-api-key>
Example:
curl -X GET https://localhost:5001/api/projects \
-H "X-API-Key: sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"How it works:
- Request includes
X-API-Keyheader with the raw API key - API key authentication handler hashes the provided key using SHA-256
- Hash is compared against stored hashes in the database
- If match found and key is active, user identity is established from associated user
- Request proceeds with user's permissions and role
LastUsedAttimestamp is updated
Claims Available:
NameIdentifier- User ID associated with the API keyName- User's display nameEmail- User's emailRole- User's role (User/Admin)ApiKeyId- The API key's unique IDApiKeyLabel- The API key's label
- Raw key: Generated using cryptographically secure random bytes (32 bytes)
- Format:
sk_prefix + 32 random alphanumeric characters - Hashing: SHA-256 hash of the full key is stored in database
- Prefix: First 12 characters stored separately for display purposes
✅ DO:
- Store API keys in environment variables or secrets management systems
- Use different API keys for different environments (dev/staging/prod)
- Rotate API keys periodically
- Use descriptive labels to identify key purpose
- Revoke keys immediately if compromised
- Monitor
lastUsedAtfor unexpected usage
❌ DON'T:
- Commit API keys to version control
- Share API keys between services
- Use the same key for multiple purposes
- Store keys in plain text files
- Log API keys in application logs
Endpoints can specify which authentication schemes they accept:
// JWT only (default for user-facing endpoints)
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<ActionResult> UserEndpoint() { }
// API Key only (for scanner/integration endpoints)
[Authorize(AuthenticationSchemes = "ApiKey")]
public async Task<ActionResult> ScannerEndpoint() { }
// Both JWT and API Key accepted
[Authorize(AuthenticationSchemes = "Bearer,ApiKey")]
public async Task<ActionResult> FlexibleEndpoint() { }Current endpoint authentication:
| Endpoint | JWT | API Key | Notes |
|---|---|---|---|
POST /api/auth/register |
❌ | ❌ | Public |
POST /api/auth/login |
❌ | ❌ | Public |
POST /api/apikeys |
✅ | ❌ | JWT required to create keys |
GET /api/apikeys |
✅ | ❌ | JWT required to list keys |
GET /api/projects |
❌ | ❌ | Public (for now) |
GET /api/projects/{id} |
❌ | ❌ | Public (for now) |
POST /api/projects |
✅ | ✅ | Both schemes accepted |
# 1. Register and login to get JWT
JWT=$(curl -s -X POST https://localhost:5001/api/auth/login \
-H "Content-Type: application/json" \
-k \
-d '{"email":"user@example.com","password":"password123"}' \
| jq -r '.token')
# 2. Create an API key using JWT
API_KEY_RESPONSE=$(curl -s -X POST https://localhost:5001/api/apikeys \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-k \
-d '{"label":"My Scanner"}')
# 3. Extract the API key (SAVE THIS IMMEDIATELY)
API_KEY=$(echo $API_KEY_RESPONSE | jq -r '.apiKey')
echo "API Key: $API_KEY"
echo "WARNING: Save this key now! It won't be shown again."
# 4. Use the API key for subsequent requests
curl -X POST https://localhost:5001/api/projects \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-k \
-d '{"name":"Scanned Project","description":"Created via API key"}'
# 5. List your API keys (using JWT, not API key)
curl -X GET https://localhost:5001/api/apikeys \
-H "Authorization: Bearer $JWT" \
-kPossible causes:
- Token is missing from request
- Token has expired (check
expclaim) - Token signature is invalid (secret mismatch)
- Token issuer/audience doesn't match configuration
Solution:
- Ensure
Authorization: Bearer <token>header is present - Login again to get a fresh token
- Verify JWT settings match between token generation and validation
Possible causes:
- User is authenticated but lacks required role
- Endpoint requires
Adminrole but user hasUserrole
Solution:
- Check endpoint's
[Authorize(Roles = "...")]attribute - Verify user's role in token claims
Check logs for detailed error messages. Common issues:
- Clock skew (token appears to be from the future)
- Secret key mismatch between environments
- Token format is malformed
- JWT.io - Decode and inspect JWT tokens
- Microsoft Identity Documentation
- OWASP Authentication Cheat Sheet