-
-
Notifications
You must be signed in to change notification settings - Fork 0
Token Storage & Security
How the SDK stores, refreshes, and protects OAuth tokens β and how to harden that storage for production.
The SDK uses a two-layer storage strategy:
| Layer | Driver | Purpose |
|---|---|---|
| Cache | Laravel Cache (configurable) | Fast reads on every API request |
| Database |
teamleader_tokens table |
Persistent source of truth across restarts |
Every read checks cache first and falls back to the database. Every write goes to the database first, then the cache. This means the database is always authoritative β a cache miss is recoverable; a database miss requires re-authentication.
The table is created automatically on the first successful OAuth callback. No php artisan migrate is needed.
teamleader_tokens
βββ id bigint (PK)
βββ access_token text
βββ refresh_token text (nullable)
βββ token_type varchar(50) default 'Bearer'
βββ expires_in integer
βββ expires_at timestamp β indexed
βββ created_at timestamp
βββ updated_at timestamp β indexed
Only one row is ever kept. On each token refresh the existing row is updated in-place. There is no token history.
| Key | Content | TTL |
|---|---|---|
teamleader_access_token |
Access token string |
expires_in β 120 s (min 60 s) |
teamleader_access_token_expires_at |
Expiry timestamp | Same as above |
teamleader_refresh_token |
Refresh token string | 7 days |
teamleader_refresh_lock |
Distributed lock flag | 60 s |
Tokens are refreshed automatically when they have less than 15 minutes remaining.
getValidAccessToken()
β
βββ read cache
β βββ miss? read database β re-cache
β
βββ token expires within 15 min?
β βββ yes β acquire distributed lock (Redis)
β βββ another worker already refreshing? β wait, then read DB
β βββ this worker wins the lock
β βββ read refresh_token from DB
β βββ POST /oauth2/access_token
β βββ write new tokens to DB first
β βββ write to cache
β βββ release lock
β
βββ return access_token
The refresh lock (teamleader_refresh_lock) is set via Cache::add() β an atomic operation that only succeeds for the first caller. This prevents multiple Laravel Horizon workers from refreshing simultaneously and racing to write conflicting tokens.
This requires a shared cache driver (Redis or database). File cache is per-process and will not coordinate across workers.
If the OAuth server returns HTTP 400 or 401 during a refresh, the SDK assumes the refresh token has been revoked. It calls clearTokens() immediately β wiping both cache and database β and returns null. The next API call will fail with an unauthenticated error, prompting re-authorisation.
By default, tokens are stored as plain text in both the cache and the database. This is fine for development but requires hardening for production.
| Storage | Default | Recommendation |
|---|---|---|
| Cache | Plain text in configured driver | Use Redis with TLS/SSL |
| Database | Plain text text columns |
Add Laravel encrypted casts |
| Cache driver | Often file in dev |
Must be redis or database in production |
File cache is per-process and stores tokens in plain text on disk. Redis is required for multi-worker deployments and strongly recommended for all production use.
CACHE_DRIVER=redis
REDIS_HOST=your-redis-host
REDIS_PASSWORD=your-redis-password
REDIS_PORT=6379
REDIS_SCHEME=tls// config/database.php β enable TLS for the Redis connection
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'scheme' => env('REDIS_SCHEME', 'tcp'), // set to 'tls' in production
'database' => env('REDIS_DB', '0'),
],Add encrypted casts to a model wrapping the teamleader_tokens table, or apply encryption in the TokenService directly. Laravel's encrypted cast uses your APP_KEY:
// If you wrap the table in a model:
protected $casts = [
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
];Laravel uses APP_KEY for encryption. If this key is ever exposed, rotate it immediately:
php artisan key:generateAfter rotating, any tokens encrypted with the old key become unreadable β clearTokens() and re-authorise.
Use a principle-of-least-privilege database user for the application. The SDK only reads and updates a single row β it does not need DDL privileges in production (the table is created on first run).
-
CACHE_DRIVERisredis(notfileorarray) - Redis is password-protected with TLS enabled
-
APP_KEYis unique, strong, and not committed to version control -
.envis excluded from version control (.gitignore) -
storage/directory permissions are770(not world-readable) - Encrypted casts applied to
access_tokenandrefresh_token - Database encryption-at-rest is enabled
- Application database user has no DDL privileges in production
- Token-refresh log events are monitored for anomalies
use McoreServices\TeamleaderSDK\Services\TokenService;
$tokenService = app(TokenService::class);
// Summary of all storage locations
$info = $tokenService->getTokenInfo();
// Returns:
// [
// 'has_access_token' => true,
// 'has_refresh_token' => true,
// 'expires_at' => '2025-05-12 14:30:00',
// 'expires_in' => 847, // seconds remaining
// 'needs_refresh' => false,
// 'token_source' => 'cache', // 'cache' | 'database' | 'none'
// 'cache_has_tokens' => true,
// 'database_has_tokens' => true,
// 'storage_sync' => [...],
// ]
// Simple boolean check (with 5-minute buffer)
$valid = $tokenService->hasValidTokens();# Show token status and expiry
php artisan teamleader:token
# Force a token refresh
php artisan teamleader:token --refresh
# Revoke and clear all tokens (forces re-authentication)
php artisan teamleader:token --revokeIf you suspect the cache is stale (e.g. after a cache flush):
$tokenService->syncTokensToCache();$tokenService->clearTokens();
// Clears: cache keys + all rows in teamleader_tokensStep 1 β Revoke access in Teamleader immediately
Go to Teamleader Focus β Marketplace β your integration β Disconnect.
Step 2 β Clear all stored tokens
php artisan tinker
>>> app(\McoreServices\TeamleaderSDK\Services\TokenService::class)->clearTokens();
>>> exit
php artisan cache:clearStep 3 β Rotate your application key (if DB encryption is in use)
php artisan key:generateStep 4 β Re-authorise
Direct your application through the OAuth flow again:
// In a controller or artisan command:
return Teamleader::authorize();Step 5 β Review access logs for any API calls made with the compromised token.
- Home β SDK overview and all resource links
- Teamleader Developers β manage OAuth app credentials
-
Laravel Encryption β
APP_KEYand encrypted casts - Redis Security β TLS, passwords, ACLs
Last Updated: August 2026 β’ SDK Version: 2.2.2 β’ Made with β€οΈ by MCore Services
- Departments
- Users
- Teams
- Custom Fields
- Work Types
- Document Templates
- Currencies
- Notes
- Email Tracking
- Closing Days
- Day Off Types
- Days Off
- User Schedules
- Invoices
- Credit Notes
- Subscriptions
- Payment Methods
- Payment Terms
- Tax Rates
- Withholding Tax Rates
- Commercial Discounts
Next Gen Projects
Legacy Projects