Authentication & JWT¶
Overview¶
GRAB implements a secure, production-ready authentication system using OAuth 2.0 Best Current Practice (BCP) compliant JWT refresh token rotation with automatic reuse detection. This approach provides enhanced security while maintaining excellent user experience.
API Response Format
All API responses use a standardized envelope format. See the API Response Format guide for complete details.
Key Features¶
- Token Pair Authentication: Access tokens (short-lived) + Refresh tokens (long-lived)
- Automatic Token Rotation: Each refresh generates a new token pair
- Reuse Detection: Automatic revocation of token families when suspicious activity is detected
- Token Family Tracking: UUID-based lineage tracking for security auditing
- SHA-256 Token Hashing: Refresh tokens are hashed before storage
- Configurable TTLs: Separate lifetimes for access and refresh tokens
- Secure Revocation: Per-user and per-family token revocation
Authentication Flow¶
Registration & Login¶
sequenceDiagram
participant Client
participant API
participant DB
Client->>API: POST /api/v1/auth/register
API->>DB: Create user
API->>DB: Store refresh token (hashed)
API->>Client: { access_token, refresh_token }
Note over Client: Store tokens securely Register Endpoint:
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePassword123!",
"name": "John Doe"
}
Response:
{
"success": true,
"data": {
"user": {
"id": 1,
"email": "user@example.com",
"name": "John Doe"
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "8f7a3c2b1e9d4a5f6c8b7e3a2f1d9c8b...",
"token_type": "Bearer",
"expires_in": 900
}
}
Login Endpoint:
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePassword123!"
}
Response format is identical to registration.
Token Refresh Flow¶
When the access token expires, use the refresh token to obtain a new token pair:
sequenceDiagram
participant Client
participant API
participant DB
Client->>API: POST /api/v1/auth/refresh<br/>{refresh_token}
API->>DB: Find token by hash
alt Token Valid & Not Used
API->>DB: Mark old token as used
API->>DB: Store new refresh token
API->>Client: { new_access_token, new_refresh_token }
else Token Already Used (Reuse Detection)
API->>DB: Revoke entire token family
API->>Client: 401 Unauthorized - Token reuse detected
else Token Expired/Revoked/Invalid
API->>Client: 401 Unauthorized
end Refresh Endpoint:
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "8f7a3c2b1e9d4a5f6c8b7e3a2f1d9c8b..."
}
Success Response:
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "9a8b7c6d5e4f3a2b1c9d8e7f6a5b4c3d...",
"token_type": "Bearer",
"expires_in": 900
}
}
Making Authenticated Requests¶
Use the access token in the Authorization header:
Logout¶
Revoke all refresh tokens for the current user:
Response:
Security Architecture¶
Token Rotation¶
Token rotation is a security best practice that limits the lifespan of refresh tokens while maintaining a seamless user experience. Each time a refresh token is used:
- ✅ The old refresh token is marked as "used" (not deleted)
- ✅ A new token pair is generated with the same token family
- ✅ The new refresh token is stored in the database (hashed)
- ✅ Old token becomes invalid for future use
Why keep used tokens? They enable reuse detection!
Reuse Detection¶
Reuse detection protects against token theft by detecting when a refresh token is used more than once:
flowchart TD
A[Client sends refresh_token] --> B{Token exists?}
B -->|No| C[Return: Invalid token]
B -->|Yes| D{Token revoked?}
D -->|Yes| E[Return: Token revoked]
D -->|No| F{Token expired?}
F -->|Yes| G[Return: Token expired]
F -->|No| H{Token already used?}
H -->|Yes| I[🚨 REUSE DETECTED<br/>Revoke entire token family]
H -->|No| J[Mark as used<br/>Generate new token pair]
I --> K[Return: Token reuse detected]
J --> L[Return: New tokens] Scenario: Token Theft
- Attacker steals a refresh token
- Attacker uses it → Gets new token pair ✅
- Legitimate user tries to use the same token → 🚨 Reuse detected!
- System revokes the entire token family (attacker's tokens become invalid)
This provides strong protection even if a token is compromised.
Token Family Tracking¶
Each token belongs to a "token family" identified by a UUID. All tokens generated from the same initial authentication share the same family ID:
Initial Login: family_id = "abc-123"
↓
First Refresh: family_id = "abc-123" (same family)
↓
Second Refresh: family_id = "abc-123" (same family)
↓
🚨 Reuse Detected → Revoke ALL tokens in family "abc-123"
Token Storage¶
Access Tokens: - Short-lived (default: 15 minutes) - Stored only on client side - Contains user claims (ID, email, name) - NOT stored in database
Refresh Tokens: - Long-lived (default: 7 days) - SHA-256 hashed before storage - Includes metadata: token_family, expires_at, used_at, revoked_at - Database indexed for fast lookup
Database Schema:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
token_family UUID NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
revoked_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_token_family ON refresh_tokens(token_family);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
JWT Claims Structure¶
Access tokens are JWTs that contain user information and roles in the payload. The token payload (claims) includes:
{
"user_id": 1,
"email": "user@example.com",
"name": "John Doe",
"roles": ["user"],
"exp": 1700000000,
"iat": 1699996400
}
Claims Description:
| Claim | Type | Description |
|---|---|---|
user_id | integer | Unique user identifier |
email | string | User's email address |
name | string | User's display name |
roles | string[] | Array of role names (e.g., ["user", "admin"]) |
exp | integer | Token expiration timestamp (Unix) |
iat | integer | Token issued at timestamp (Unix) |
Role-Based Access:
The roles claim is populated automatically based on the user's assigned roles in the database:
- New users get
["user"]by default - Admins have
["user", "admin"] - Custom roles can be added (e.g.,
["user", "moderator"])
The API validates roles on every request to protected endpoints. See RBAC for complete role management documentation.
Decoding Tokens:
Access tokens can be decoded on the client to extract user info and roles without making an API call:
// Browser (using jwt-decode library)
import jwtDecode from 'jwt-decode';
const decoded = jwtDecode(accessToken);
console.log(decoded.roles); // ["user", "admin"]
Token Security
- Access tokens are signed but NOT encrypted - don't store sensitive data in claims
- Always validate tokens server-side - never trust client-side validation alone
- Roles in JWT are cached until token expires - role changes require re-login
Configuration¶
Environment Variables¶
Configure token lifetimes via environment variables or config files:
# JWT Configuration
JWT_SECRET=your-super-secret-key-min-32-chars
JWT_ACCESS_TOKEN_TTL=15m # Access token lifetime
JWT_REFRESH_TOKEN_TTL=168h # Refresh token lifetime (7 days)
Config File¶
configs/config.yaml:
Recommended TTL Values¶
| Environment | Access Token | Refresh Token | Rationale |
|---|---|---|---|
| Development | 15m | 7 days | Balance between security and DX |
| Staging | 15m | 7 days | Match production behavior |
| Production | 15m | 7-30 days | Based on security requirements |
Guidelines:
- ✅ Access tokens: 5-15 minutes (frequently rotated, high security)
- ✅ Refresh tokens: 7-30 days (balance between security and UX)
- ❌ Don't: Set access tokens > 1 hour (defeats rotation purpose)
- ❌ Don't: Set refresh tokens > 90 days (compliance risk)
API Reference¶
Authentication Endpoints¶
| Endpoint | Method | Auth Required | Description |
|---|---|---|---|
/api/v1/auth/register | POST | No | Create new user account |
/api/v1/auth/login | POST | No | Authenticate and get tokens |
/api/v1/auth/refresh | POST | No | Refresh access token |
/api/v1/auth/logout | POST | Yes | Revoke all user tokens |
Register¶
Request:
{
"email": "string (required, email format)",
"password": "string (required, min 8 chars)",
"name": "string (required, min 1 char)"
}
Response (200 OK):
{
"success": true,
"data": {
"user": {
"id": "integer",
"email": "string",
"name": "string"
},
"access_token": "string (JWT)",
"refresh_token": "string (random 32 bytes, hex)",
"token_type": "Bearer",
"expires_in": "integer (seconds)"
}
}
Error Responses:
400 Bad Request: Validation errors409 Conflict: Email already exists500 Internal Server Error: Server error
Login¶
Request:
Response: Same as Register
Error Responses:
400 Bad Request: Validation errors401 Unauthorized: Invalid credentials500 Internal Server Error: Server error
Refresh Token¶
Request:
Response (200 OK):
{
"success": true,
"data": {
"access_token": "string (JWT)",
"refresh_token": "string (new token)",
"token_type": "Bearer",
"expires_in": "integer (seconds)"
}
}
Error Responses:
400 Bad Request: Missing refresh_token401 Unauthorized: Invalid/expired/revoked token401 Unauthorized(reuse): Token reuse detected - all family tokens revoked500 Internal Server Error: Server error
Logout¶
Request:
Requires Authorization: Bearer <access_token> header
Response (200 OK):
Error Responses:
401 Unauthorized: Missing/invalid access token500 Internal Server Error: Server error
Client Implementation Guide¶
Best Practices¶
- Store tokens securely:
- Web: HttpOnly cookies (most secure) or sessionStorage (never localStorage for refresh tokens)
- Mobile: Secure storage (Keychain/Keystore)
-
Never expose refresh tokens in URLs or logs
-
Implement automatic refresh:
- Detect 401 responses
- Attempt token refresh
- Retry original request with new token
-
If refresh fails, redirect to login
-
Handle reuse detection:
- On "token reuse detected" error, immediately clear all tokens
- Redirect user to login
- Optional: Show security alert
JavaScript/TypeScript Example¶
class AuthClient {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private refreshPromise: Promise<void> | null = null;
async login(email: string, password: string): Promise<void> {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
this.setTokens(data.access_token, data.refresh_token);
}
async refreshAccessToken(): Promise<void> {
// Prevent concurrent refresh attempts
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = (async () => {
try {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: this.refreshToken })
});
if (!response.ok) {
// Token reuse or expired - clear everything
this.clearTokens();
throw new Error('Refresh failed');
}
const data = await response.json();
this.setTokens(data.access_token, data.refresh_token);
} finally {
this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
async request(url: string, options: RequestInit = {}): Promise<Response> {
// Add access token to request
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${this.accessToken}`);
let response = await fetch(url, { ...options, headers });
// If 401, try to refresh and retry
if (response.status === 401 && this.refreshToken) {
try {
await this.refreshAccessToken();
headers.set('Authorization', `Bearer ${this.accessToken}`);
response = await fetch(url, { ...options, headers });
} catch (error) {
// Refresh failed - redirect to login
window.location.href = '/login';
throw error;
}
}
return response;
}
async logout(): Promise<void> {
if (this.accessToken) {
await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` }
});
}
this.clearTokens();
}
private setTokens(accessToken: string, refreshToken: string): void {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
// Store in secure storage (e.g., sessionStorage)
sessionStorage.setItem('access_token', accessToken);
sessionStorage.setItem('refresh_token', refreshToken);
}
private clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token');
}
}
// Usage
const auth = new AuthClient();
// Login
await auth.login('user@example.com', 'password');
// Make authenticated request (auto-refresh on 401)
const response = await auth.request('/api/v1/users/1');
const user = await response.json();
// Logout
await auth.logout();
Mobile App Example (React Native)¶
import * as SecureStore from 'expo-secure-store';
class MobileAuthClient {
private async getTokens() {
const accessToken = await SecureStore.getItemAsync('access_token');
const refreshToken = await SecureStore.getItemAsync('refresh_token');
return { accessToken, refreshToken };
}
private async setTokens(accessToken: string, refreshToken: string) {
await SecureStore.setItemAsync('access_token', accessToken);
await SecureStore.setItemAsync('refresh_token', refreshToken);
}
private async clearTokens() {
await SecureStore.deleteItemAsync('access_token');
await SecureStore.deleteItemAsync('refresh_token');
}
async login(email: string, password: string) {
const response = await fetch('https://api.example.com/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
await this.setTokens(data.access_token, data.refresh_token);
return data;
}
async authenticatedRequest(url: string, options: RequestInit = {}) {
const { accessToken, refreshToken } = await this.getTokens();
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${accessToken}`);
let response = await fetch(url, { ...options, headers });
if (response.status === 401 && refreshToken) {
// Try to refresh
const refreshResponse = await fetch('https://api.example.com/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (refreshResponse.ok) {
const data = await refreshResponse.json();
await this.setTokens(data.access_token, data.refresh_token);
// Retry original request
headers.set('Authorization', `Bearer ${data.access_token}`);
response = await fetch(url, { ...options, headers });
} else {
// Refresh failed - clear tokens and require login
await this.clearTokens();
throw new Error('Session expired');
}
}
return response;
}
}
Security Considerations¶
Production Checklist¶
- JWT Secret: Use strong secret (min 32 characters, random)
- HTTPS Only: Never use tokens over HTTP
- Secure Storage: Use HttpOnly cookies or secure storage APIs
- Token Expiry: Keep access tokens short-lived (≤ 15 minutes)
- Rate Limiting: Protect auth endpoints (implemented in GRAB)
- Input Validation: Validate all auth requests (implemented in GRAB)
- Password Hashing: Use bcrypt with cost ≥ 12 (implemented in GRAB)
- CORS: Configure proper CORS policies
- Monitoring: Log auth failures and token reuse events
- Revocation: Implement logout and token revocation
Common Vulnerabilities & Mitigations¶
| Vulnerability | Mitigation in GRAB |
|---|---|
| Token Theft | Reuse detection + automatic family revocation |
| XSS Attacks | Use HttpOnly cookies or secure storage |
| CSRF Attacks | JWT in Authorization header (not cookies) |
| Replay Attacks | Token rotation + short TTLs |
| Brute Force | Rate limiting on auth endpoints |
| Session Fixation | New token family per authentication |
Troubleshooting¶
Common Issues¶
Issue: "Invalid token" error
- ✅ Check token hasn't expired
- ✅ Verify token format (should be JWT for access, hex string for refresh)
- ✅ Ensure proper Authorization header format:
Bearer <token> - ✅ Confirm JWT_SECRET matches between environments
Issue: "Token reuse detected"
- This is a security feature, not a bug
- Occurs when same refresh token used twice
- Solution: User must log in again
- Prevention: Ensure client properly updates tokens after refresh
Issue: Refresh token not working after logout
- Expected behavior - logout revokes all user tokens
- User must log in again to get new tokens
Issue: Access token expires too quickly
- Default is 15 minutes (security best practice)
- Don't increase beyond 1 hour
- Implement automatic refresh in client instead
Debug Mode¶
Enable debug logging in development:
Standards & References¶
GRAB's authentication implementation follows industry best practices and standards:
- OAuth 2.0 Best Current Practice (BCP): Token rotation and reuse detection
- RFC 7519 (JWT): JSON Web Token standard
- RFC 6750 (Bearer Token): OAuth 2.0 Bearer Token usage
- OWASP Authentication Cheat Sheet: Security best practices
Additional Resources:
Next Steps¶
- 📚 Rate Limiting - Protect auth endpoints
- 🔍 Error Handling - Understand auth error responses
- 🐳 Docker Guide - Deploy with proper secrets
- 📊 Swagger API Docs - Interactive API testing