487 lines
11 KiB
Markdown
487 lines
11 KiB
Markdown
# JWT vs Random Tokens - Which is Better?
|
|||
|
|
|
||
|
|
Analyzing token types for Authentik + SOPS workflow.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Quick Comparison
|
||
|
|
|
||
|
|
| Aspect | JWT | Random Token |
|
||
|
|
|--------|-----|--------------|
|
||
|
|
| **Stateless** | ✅ Yes | ❌ Requires lookup |
|
||
|
|
| **Self-contained** | ✅ Yes (claims inside) | ❌ Opaque |
|
||
|
|
| **Can verify locally** | ✅ Yes (signature) | ❌ Must call Authentik |
|
||
|
|
| **Size** | ⚠️ Larger (~500 bytes) | ✅ Smaller (~32 bytes) |
|
||
|
|
| **Immediate revocation** | ❌ Hard (token already signed) | ✅ Easy |
|
||
|
|
| **Contains user info** | ✅ Yes | ❌ No |
|
||
|
|
| **Standard OAuth2** | ⚠️ Optional (Bearer tokens) | ✅ Standard |
|
||
|
|
| **Good for audit** | ✅ User/groups embedded | ⚠️ Need to log lookup |
|
||
|
|
| **Git-friendly** | ✅ Sign commits | ✅ Sign commits |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Use Case: Authentik + SOPS + Git Hooks
|
||
|
|
|
||
|
|
### Scenario 1: Developer Pushes Secret Update
|
||
|
|
|
||
|
|
```
|
||
|
|
Developer:
|
||
|
|
$ export SOPS_TOKEN=$token
|
||
|
|
$ sops secrets/default/db-creds.enc.yaml
|
||
|
|
$ git push
|
||
|
|
|
||
|
|
Git server hook runs:
|
||
|
|
├─ Receive token from commit metadata
|
||
|
|
├─ Need to validate token
|
||
|
|
└─ Two options:
|
||
|
|
```
|
||
|
|
|
||
|
|
**With JWT Token:**
|
||
|
|
```
|
||
|
|
Git hook:
|
||
|
|
1. Extract JWT from commit
|
||
|
|
2. Verify signature locally (no API call)
|
||
|
|
3. Read claims: {sub: "[email protected]", groups: ["k8s:secret-admin"]}
|
||
|
|
4. Check: is user in k8s:secret-admin group?
|
||
|
|
5. Allow/reject commit
|
||
|
|
|
||
|
|
Benefits:
|
||
|
|
✅ No need to call Authentik API
|
||
|
|
✅ Token self-validates
|
||
|
|
✅ Can check groups locally
|
||
|
|
✅ Instant verification
|
||
|
|
✅ Works offline
|
||
|
|
```
|
||
|
|
|
||
|
|
**With Random Token:**
|
||
|
|
```
|
||
|
|
Git hook:
|
||
|
|
1. Extract token from commit
|
||
|
|
2. Call Authentik API: GET /application/o/introspect/
|
||
|
|
└─ "Is this token valid?"
|
||
|
|
3. If valid, check groups: GET /api/v3/users/{id}/groups/
|
||
|
|
4. If admin, allow commit
|
||
|
|
|
||
|
|
Problems:
|
||
|
|
❌ Need API call on every git push
|
||
|
|
❌ Slow (network latency)
|
||
|
|
❌ Requires network connectivity
|
||
|
|
❌ If Authentik is down, can't push
|
||
|
|
❌ Rate limiting risk (many API calls)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## JWT Structure (What's Inside)
|
||
|
|
|
||
|
|
```json
|
||
|
|
// Decoded JWT payload
|
||
|
|
{
|
||
|
|
"sub": "[email protected]",
|
||
|
|
"email": "[email protected]",
|
||
|
|
"name": "John Doe",
|
||
|
|
"groups": [
|
||
|
|
"k8s:secret-admin",
|
||
|
|
"k8s:namespace:default:editor"
|
||
|
|
],
|
||
|
|
"iat": 1705336200,
|
||
|
|
"exp": 1705422600, // Expires in 24 hours
|
||
|
|
"iss": "https://authentik.riotpiao.com",
|
||
|
|
"aud": "secrets-management"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**In git commit:**
|
||
|
|
```
|
||
|
|
commit abc1234...
|
||
|
|
Author: [email protected] <[email protected]>
|
||
|
|
Date: Tue Jan 16 14:30:00 2025 +0000
|
||
|
|
|
||
|
|
chore(secret): update db-creds
|
||
|
|
|
||
|
|
X-SOPS-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||
|
|
```
|
||
|
|
|
||
|
|
**Git hook can:**
|
||
|
|
```
|
||
|
|
1. Extract token from commit
|
||
|
|
2. Decode (no signature needed yet)
|
||
|
|
3. Read claims: sub, groups
|
||
|
|
4. Verify signature with Authentik's public key
|
||
|
|
5. Check groups locally
|
||
|
|
6. Decision: accept or reject
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## JWT Benefits for This Use Case
|
||
|
|
|
||
|
|
### 1. Local Token Verification (No API Calls)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Git hook code (serverless, fast)
|
||
|
|
function validateJWT(token: string) {
|
||
|
|
const decoded = jwt.verify(token, PUBLIC_KEY);
|
||
|
|
// ✅ Done instantly
|
||
|
|
// ✅ No network call
|
||
|
|
// ✅ Can work offline
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Embedded Claims (Audit Trail)
|
||
|
|
|
||
|
|
```
|
||
|
|
JWT contains:
|
||
|
|
├─ sub: who made the change
|
||
|
|
├─ groups: what permissions they had
|
||
|
|
├─ iat: when token was issued
|
||
|
|
└─ exp: when it expires
|
||
|
|
|
||
|
|
Git commit metadata automatically includes:
|
||
|
|
└─ Who, what groups, when
|
||
|
|
|
||
|
|
Complete audit trail without additional logging.
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. RBAC at Git Level
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Git hook can enforce RBAC locally
|
||
|
|
function canPushSecrets(token: JWT) {
|
||
|
|
const groups = token.groups;
|
||
|
|
|
||
|
|
// Only k8s:secret-admin can push to secrets/
|
||
|
|
if (!groups.includes("k8s:secret-admin")) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. No Dependency on Authentik Being Up
|
||
|
|
|
||
|
|
```
|
||
|
|
If Authentik is temporarily down:
|
||
|
|
✅ Developers can still push (JWT validates locally)
|
||
|
|
✅ Works offline
|
||
|
|
❌ With random tokens: push fails
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Temporal Can Verify Tokens Locally
|
||
|
|
|
||
|
|
```
|
||
|
|
Temporal workflow (token rotation):
|
||
|
|
└─ Reads old token from K8s
|
||
|
|
└─ Decodes JWT: check exp field
|
||
|
|
└─ "Is this token about to expire?"
|
||
|
|
└─ Generate new token
|
||
|
|
|
||
|
|
No API call needed to validate old token.
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Random Token Benefits
|
||
|
|
|
||
|
|
### 1. Standard OAuth2 Practice
|
||
|
|
|
||
|
|
```
|
||
|
|
Random tokens are what most OAuth2 providers use:
|
||
|
|
├─ Opaque (attacker doesn't know content)
|
||
|
|
├─ Standard bearer tokens
|
||
|
|
└─ What Authentik probably generates by default
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Immediate Revocation
|
||
|
|
|
||
|
|
```
|
||
|
|
If token is compromised:
|
||
|
|
└─ Delete it from Authentik
|
||
|
|
└─ All git pushes instantly fail
|
||
|
|
|
||
|
|
With JWT:
|
||
|
|
└─ Token is still valid until exp time
|
||
|
|
└─ Attacker has until expiry
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Smaller Size
|
||
|
|
|
||
|
|
```
|
||
|
|
JWT: ~500 bytes (base64 encoded)
|
||
|
|
Random: ~32 bytes (hex)
|
||
|
|
|
||
|
|
Difference:
|
||
|
|
✅ Slightly smaller in git metadata
|
||
|
|
❌ But negligible for practical purposes
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Simpler Conceptually
|
||
|
|
|
||
|
|
```
|
||
|
|
Developers understand random tokens:
|
||
|
|
└─ "Here's a secret token, use it"
|
||
|
|
|
||
|
|
Developers don't understand JWT:
|
||
|
|
└─ "What's this long base64 string?"
|
||
|
|
└─ "Why do I need to decode it?"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## My Recommendation: JWT
|
||
|
|
|
||
|
|
Here's why for YOUR use case:
|
||
|
|
|
||
|
|
### 1. Git Hook Verification
|
||
|
|
```
|
||
|
|
With JWT:
|
||
|
|
Git hook validates token signature locally
|
||
|
|
└─ No Authentik API call
|
||
|
|
└─ Fast
|
||
|
|
└─ Works offline
|
||
|
|
└─ Scales infinitely
|
||
|
|
|
||
|
|
With random token:
|
||
|
|
Git hook calls Authentik API
|
||
|
|
└─ Network latency
|
||
|
|
└─ Authentik bottleneck
|
||
|
|
└─ Fails if Authentik down
|
||
|
|
└─ Rate limiting risk
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Embedded RBAC
|
||
|
|
```
|
||
|
|
With JWT:
|
||
|
|
Git hook reads groups from token
|
||
|
|
└─ Only k8s:secret-admin can push to secrets/
|
||
|
|
└─ Enforced locally
|
||
|
|
└─ No additional database lookups
|
||
|
|
|
||
|
|
With random token:
|
||
|
|
Git hook calls Authentik API twice
|
||
|
|
└─ Introspect token
|
||
|
|
└─ Fetch groups
|
||
|
|
└─ Slower
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Audit Trail
|
||
|
|
```
|
||
|
|
With JWT:
|
||
|
|
Git commit includes token claims
|
||
|
|
└─ User, groups, timestamp all in commit metadata
|
||
|
|
└─ Complete audit trail automatically
|
||
|
|
|
||
|
|
With random token:
|
||
|
|
Git commit has only token
|
||
|
|
└─ Need to log token → user mapping separately
|
||
|
|
└─ Additional logging overhead
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Temporal Token Rotation
|
||
|
|
```
|
||
|
|
With JWT:
|
||
|
|
Temporal reads exp field
|
||
|
|
└─ "Token expires in 2 hours"
|
||
|
|
└─ No API call needed
|
||
|
|
|
||
|
|
With random token:
|
||
|
|
Temporal calls Authentik API
|
||
|
|
└─ "Is this token still valid?"
|
||
|
|
└─ Network call needed
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## How to Get JWT from Authentik
|
||
|
|
|
||
|
|
### Option 1: OpenID Connect (Standard)
|
||
|
|
```
|
||
|
|
Authentik already supports OIDC:
|
||
|
|
|
||
|
|
Developer requests token:
|
||
|
|
$ curl -X POST https://authentik.../application/o/token/ \
|
||
|
|
-d "grant_type=password&username=user&password=pwd&scope=openid profile groups"
|
||
|
|
|
||
|
|
Response:
|
||
|
|
{
|
||
|
|
"access_token": "eyJhbGc...", ← JWT
|
||
|
|
"token_type": "Bearer",
|
||
|
|
"expires_in": 86400,
|
||
|
|
"id_token": "eyJhbGc..."
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Option 2: OAuth2 with JWT
|
||
|
|
```
|
||
|
|
Authentik OAuth2 provider settings:
|
||
|
|
├─ Enable: "Use claims-based access tokens"
|
||
|
|
├─ Token claims: username, email, groups, preferred_username
|
||
|
|
└─ Signature algorithm: RS256
|
||
|
|
|
||
|
|
Result: Access token is now JWT instead of random.
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Implementation: SOPS + JWT
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Developer gets JWT token from Authentik
|
||
|
|
$ token=$(oidc-client get-token)
|
||
|
|
$ echo $token # base64 encoded JWT, not random string
|
||
|
|
|
||
|
|
# SOPS commits with JWT
|
||
|
|
$ export SOPS_TOKEN=$token
|
||
|
|
$ sops secrets/default/db-creds.enc.yaml
|
||
|
|
$ git add . && git commit -m "update" && git push
|
||
|
|
|
||
|
|
# Git hook receives commit
|
||
|
|
$ git hook:
|
||
|
|
# 1. Extract SOPS_TOKEN from commit metadata
|
||
|
|
# 2. Decode JWT (no API call)
|
||
|
|
# 3. Verify signature with Authentik public key
|
||
|
|
# 4. Read claims: groups
|
||
|
|
# 5. Check: user in k8s:secret-admin?
|
||
|
|
# 6. Accept or reject push
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Comparison in Your Architecture
|
||
|
|
|
||
|
|
### Random Token Path
|
||
|
|
```
|
||
|
|
Developer → Authentik (get random token)
|
||
|
|
→ SOPS (encrypt + commit)
|
||
|
|
→ Git push
|
||
|
|
→ Git hook (calls Authentik API to validate)
|
||
|
|
→ ArgoCD
|
||
|
|
→ K8s
|
||
|
|
|
||
|
|
Network calls: 2 (Authentik for token + git hook validation)
|
||
|
|
```
|
||
|
|
|
||
|
|
### JWT Token Path
|
||
|
|
```
|
||
|
|
Developer → Authentik (get JWT token)
|
||
|
|
→ SOPS (encrypt + commit)
|
||
|
|
→ Git push
|
||
|
|
→ Git hook (validates JWT locally, no API call)
|
||
|
|
→ ArgoCD
|
||
|
|
→ K8s
|
||
|
|
|
||
|
|
Network calls: 1 (only Authentik for token issuance)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Winner: JWT (fewer API calls)**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Concerns with JWT
|
||
|
|
|
||
|
|
### Q: Can JWT be revoked immediately?
|
||
|
|
|
||
|
|
**A:** Not easily. Options:
|
||
|
|
|
||
|
|
```
|
||
|
|
1. Token expires (set exp field to short time, e.g., 1 hour)
|
||
|
|
└─ Temporal rotates daily anyway
|
||
|
|
|
||
|
|
2. Blacklist approach (store revoked JWTs)
|
||
|
|
└─ Git hook checks blacklist before validating
|
||
|
|
└─ But defeats purpose of stateless tokens
|
||
|
|
|
||
|
|
3. Accept that JWT lives until expiry
|
||
|
|
└─ In your case: Temporal rotates daily
|
||
|
|
└─ So max time a compromised token is valid: 24 hours
|
||
|
|
└─ Acceptable for homelab
|
||
|
|
```
|
||
|
|
|
||
|
|
### Q: Are JWTs vulnerable?
|
||
|
|
|
||
|
|
**A:** No, if configured right:
|
||
|
|
|
||
|
|
```
|
||
|
|
✅ Signed with RS256 (Authentik public key)
|
||
|
|
✅ Signature verified on every use
|
||
|
|
✅ Expiry checked (can't use expired token)
|
||
|
|
✅ Can't be forged (no private key to sign with)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Q: Does Authentik support JWT?
|
||
|
|
|
||
|
|
**A:** Yes, fully:
|
||
|
|
|
||
|
|
```
|
||
|
|
Authentik has:
|
||
|
|
✅ OIDC (returns JWT id_token + access_token)
|
||
|
|
✅ OAuth2 with claims (can return JWT)
|
||
|
|
✅ Configuration for JWT signing algorithm
|
||
|
|
✅ Public key endpoint for verification
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Final Recommendation
|
||
|
|
|
||
|
|
### Use JWT if:
|
||
|
|
```
|
||
|
|
✅ You want fast git hook validation (no API calls)
|
||
|
|
✅ You want embedded RBAC (groups in token)
|
||
|
|
✅ You want complete audit trail (claims in metadata)
|
||
|
|
✅ You want resilience (works if Authentik down)
|
||
|
|
✅ You want to scale (no API bottleneck)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Use Random Token if:
|
||
|
|
```
|
||
|
|
✅ Immediate revocation is critical
|
||
|
|
✅ You prefer standard OAuth2 approach
|
||
|
|
✅ Developers shouldn't see token contents
|
||
|
|
✅ Simplicity is more important than features
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## My Advice
|
||
|
|
|
||
|
|
**Go with JWT.**
|
||
|
|
|
||
|
|
Here's why:
|
||
|
|
1. Authentik supports it natively
|
||
|
|
2. Git hook verification is instant (no API calls)
|
||
|
|
3. Embedded claims give you free audit trail
|
||
|
|
4. Works offline (resilient)
|
||
|
|
5. Perfect for homelab scale
|
||
|
|
|
||
|
|
**Setup:**
|
||
|
|
1. Configure Authentik OIDC provider
|
||
|
|
2. Enable "Use claims-based access tokens" option
|
||
|
|
3. Add groups to token claims
|
||
|
|
4. Developers get JWT instead of random string
|
||
|
|
5. Git hook validates JWT locally (no API call)
|
||
|
|
|
||
|
|
**Code example for git hook:**
|
||
|
|
```bash
|
||
|
|
#!/bin/bash
|
||
|
|
# .git/hooks/update (server-side)
|
||
|
|
|
||
|
|
token=$(git log -1 --pretty=%B | grep "X-SOPS-Token" | cut -d' ' -f2)
|
||
|
|
|
||
|
|
# Verify JWT signature
|
||
|
|
jwt verify $token --key /path/to/authentik/public.key
|
||
|
|
|
||
|
|
# Decode and check groups
|
||
|
|
groups=$(jwt decode $token | jq .groups)
|
||
|
|
if [[ ! "$groups" =~ "k8s:secret-admin" ]]; then
|
||
|
|
echo "Access denied: not in k8s:secret-admin group"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Allow push
|
||
|
|
exit 0
|
||
|
|
```
|
||
|
|
|
||
|
|
That's it. Simple, elegant, no redundancy.
|