DEV Community

Cover image for Securing the Source-Score API: Diving into JWT Authentication and Client Adaptations
Amit Singh
Amit Singh

Posted on

Securing the Source-Score API: Diving into JWT Authentication and Client Adaptations

TL;DR

I replaced a fragile API‑key system with JWT‑based authentication, updated the Swagger UI, hardened the ingestion pipelines, and refactored the dashboard UI to use a shared auth.js module. The result is a secure, client‑aware API that scales without breaking existing workflows.


1️⃣ Why Securing the API Was Critical

1.1 The Original API‑Key Problem

The source-score service originally relied on a static API‑key middleware. Every client (Swagger UI, CI jobs, the dashboard) had to embed the same secret token in the X-API-Key header.

1.2 Threat Model

  1. Token Leakage – If a key is checked into source control, anyone can call privileged endpoints.
  2. Permission Abuse – A compromised client can perform destructive actions (e.g., delete sources).
  3. Replay Attacks – Without expiration, a stolen key can be reused indefinitely.

1.3 The Solution Path

We needed a dynamic, client‑aware token that could be rotated automatically, scoped per client, and expire. JWTs (JSON Web Tokens) fit the bill:

  • Self‑contained claims (client ID, expiration) → no server‑side session store.
  • Standard cryptographic signing → tamper‑proof.
  • Middleware support in Gin (our Go web framework) → quick integration.

2️⃣ JWT Migration in source-score

2.1 Introducing the /auth/token Endpoint

I added a new POST endpoint that issues a signed JWT. This endpoint is exempt from auth checks so any client that provides a Client-ID header can obtain a token.

type TokenResponse struct {
    Token string `json:"token"`
}

// POST /auth/token
func IssueToken(c *gin.Context) {
    clientID := c.GetHeader("Client-ID")
    if clientID == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Client-ID header missing"})
        return
    }

    // Build JWT claims
    claims := jwt.MapClaims{
        "aud": clientID,               // Audience = client identifier
        "iss": "source-score",         // Issuer – hard‑coded for now
        "exp": time.Now().Add(4 * time.Hour).Unix(), // 4‑hour expiry
    }

    // Sign the token with the secret from the environment
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sign token"})
        return
    }

    c.JSON(http.StatusOK, TokenResponse{Token: signed})
}
Enter fullscreen mode Exit fullscreen mode

What the code does:

  • Pulls the Client-ID header; aborts if missing.
  • Builds a claim set that includes the audience, issuer, and a 4‑hour expiration.
  • Signs the token using the secret stored in JWT_SECRET environment variable.
  • Returns the token as JSON ({ "token": "<jwt>" }).

The token vending logic so far is simple, that's why I'm keeping it in the handler layer only. The validation only requires a Client-ID header but it should be easy to add basic authentication on top of that and then I'll add service and repo layers for the auth flow as well.

2.2 Auth Middleware: Enforcing JWT Validation

All protected routes now run through JWTAuthMiddleware. The middleware validates the token, checks the audience, and ensures the token hasn't expired.

func JWTAuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        clientID := c.GetHeader("Client-ID")
        authHeader := c.GetHeader("Authorization")
        if clientID == "" || authHeader == "" {
            c.AbortWithStatusJSON(http.Status400BadRequest,
                gin.H{"error": "missing Client-ID or Authorization header"})
            return
        }

        tokenStr := strings.TrimPrefix(authHeader, "Bearer ")

        // Parse and validate the token
        token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
            // Ensure we only accept HS256 signatures
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method")
            }
            return []byte(os.Getenv("JWT_SECRET")), nil
        }, jwt.WithAudience(clientID), jwt.WithIssuer("source-score"), jwt.WithValidMethods([]string{"HS256"}))

        if err != nil || !token.Valid {
            c.AbortWithStatusJSON(http.Status401Unauthorized,
                gin.H{"error": "invalid or expired token"})
            return
        }

        c.Next()
    }
}
Enter fullscreen mode Exit fullscreen mode

Key checks:

  • Audience (aud) matches the Client-ID header → prevents token reuse across services.
  • Issuer (iss) matches "source-score" → ensures the token was issued by source-score.
  • Signature verified with JWT_SECRET.
  • Expiration automatically enforced by the JWT library.

2.3 GORM‑Level Database Guarantees

While the JWT layer secures the API surface, I also hardened the data layer with GORM fields and associations tags. I really like this feature because with a simple change it adds validation at database level, providing a second line of defense.

type Source struct {
    UriDigest string `gorm:"primaryKey;size:64"`                     // Primary key, fixed length
    Uri       string `gorm:"not null;uniqueIndex"`                    // Must be unique and non‑null
    Score     float64 `gorm:"not null;default:0;check:score >= 0 AND score <= 1"` // Enforce 0‑1 range
}
Enter fullscreen mode Exit fullscreen mode
  • Primary key ensures each source is unique.
  • Unique index on Uri prevents duplicate entries.
  • Check constraint guarantees scores stay within the valid range, protecting against accidental out‑of‑bounds writes.

2.4 Proof origin check

To avoid "trust me bro" type proofs, I added a simple host‑comparison logic at claim model service layer that rejects proof POST requests where the proof originate from the same domain as the claim:

func SameHost(url1, url2 string) (bool, error) {
    a, err := url.Parse(url1)
    if err != nil {
        return false, fmt.Errorf("invalid URL %q: %w", url1, err)
    }
    b, err := url.Parse(url2)
    if err != nil {
        return false, fmt.Errorf("invalid URL %q: %w", url2, err)
    }
    return strings.EqualFold(a.Hostname(), b.Hostname()), nil
}
Enter fullscreen mode Exit fullscreen mode

This is a first‑line defense; more sophisticated checks (e.g., cross‑domain ownership) can be added later.


3️⃣ Securing the Ingestion Workflows in sources

The ingestion pipelines run in CI (GitHub Actions) and had been using a hardcoded API key for authentication. Now that we have moved to JWT, they need to fetch a token before calling the API.

3.1 JWT‑Protected Scripts

Each script now performs a pre‑flight token request:

TOKEN=$(curl -s -X POST -H "Client-ID: gh-workflow" "$API_URL/auth/token" | jq -r .token)

# Use the token for the actual API call
curl -H "Authorization: Bearer $TOKEN" \
     -H "Client-ID: gh-workflow" \
     "$API_URL/api/v1/claims"
Enter fullscreen mode Exit fullscreen mode

This eliminates the need for a static API key in the CI environment, reducing the risk of secret leakage.

3.2 Proof Ingestion Limits (MAX_PROOF_COUNT)

To avoid overwhelming the DB with redundant proofs, I introduced an environment‑controlled limit:

MAX_PROOF_COUNT = int(os.getenv("MAX_PROOF_COUNT", "3"))
if len(claim_proofs) >= MAX_PROOF_COUNT:
    print(f"max proofs already ingested for claim {claim['uri']}, skipping...")
    continue
}
Enter fullscreen mode Exit fullscreen mode

Keeping it configurable via an environment variable makes it easy to raise or lower the limit without code changes.

skipping already validated proofs

3.3 Self‑Referential Proof Guard

The microservice already has a validation to prevent addition of self‑referential proofs but we can add another check here to avoid making a wasteful API call.

def same_domain(url1: str, url2: str) -> bool:
    host1 = (urlparse(url1).hostname or "").lower()
    host2 = (urlparse(url2).hostname or "").lower()
    return bool(host1 and host2) and host1 == host2
Enter fullscreen mode Exit fullscreen mode

If the function returns true, the proof is skipped.


4️⃣ Dashboard UI Refactor: Centralized Authentication in source-score-dashboard

The dashboard UI previously contained duplicate code blocks to connect to the API server in each page (index.html, claims.html, and proofs.html). This caused code bloat as each page re‑implemented the same logic and overall the code was becoming hard to maintain because any change to the auth flow required editing every page.

On top of that the dashboard also needed to migrate to jwt authentication. So I decided to kill 2 birds with one stone.

4.1 Introducing auth.js

I extracted the logic into a simple module (public/js/auth.js) that handles:

  1. Fetching a fresh JWT from /auth/token.
  2. Caching the token in localStorage with its expiration timestamp.
  3. Automatic expiry handling via setTimeout.
  4. Providing a helper getAuthHeaders() that returns the required headers for any API call.
const CLIENT_ID = 'web-dashboard';
const TOKEN_KEY = 'sourceScoreJwtToken';

function cacheAuthToken(token) {
    const expiresAt = getTokenExpiresAt(token);
    if (Date.now() >= expiresAt) throw new Error('Received expired token');
    const cached = { token, expiresAt };
    try {
        localStorage.setItem(
            AUTH_CACHE_KEY,
            JSON.stringify(cached)
        );
    } catch (err) {
        console.warn('Failed to cache auth token:', err);
    }
    scheduleTokenInvalidation(cached);
    return token;
}

async function fetchAuthToken() {
    const res = await fetch(AUTH_TOKEN_URL, {
        method: 'POST',
        headers: { 'Client-ID': CLIENT_ID },
        signal: AbortSignal.timeout(90000),
    });
    if (!res.ok) throw new Error(`Token HTTP ${res.status}`);
    const data = await res.json();
    if (!data || !data.token) throw new Error('Token response missing token');
    return cacheAuthToken(data.token);
}

async function getAuthToken() {
    const cached = getCachedAuthToken();
    if (cached) return cached.token;
    if (!tokenRequest) {
        tokenRequest = fetchAuthToken().finally(() => {
            tokenRequest = null;
        });
    }
    return tokenRequest;
}

async function getApiHeaders() {
    const token = await getAuthToken();
    return {
        'Client-ID': CLIENT_ID,
        'Authorization': `Bearer ${token}`
    };
} 

window.SourceScoreAuth = {
    apiBase: API_BASE,
    getApiHeaders,
    getAuthToken,
    invalidateAuthToken
};
Enter fullscreen mode Exit fullscreen mode

Key sections:

  • fetchToken(): Calls /auth/token, decodes the JWT to extract the exp claim, stores the token with its expiry, and schedules automatic removal.
  • scheduleExpiry(): Sets a timeout that clears the cached token when it expires, ensuring the UI never uses a stale token.
  • getAuthHeaders(): Returns the appropriate headers, fetching a new token if none is cached or if it has expired.

Since I have little experience with Javascript, most of this code is AI generated. Auth flow is working as expected so we're good for now.

4.2 Updating UI Pages

All pages now import the module and request headers before making API calls:

headers = await SourceScoreAuth.getApiHeaders();
const res = await fetch(API_URL_SOURCES, { headers });
Enter fullscreen mode Exit fullscreen mode
  • Single source of truth for authentication.
  • Reduced network chatter – tokens are cached until they expire.
  • Consistent error handling – any 401 response can trigger a token refresh centrally.

updated dashboard


5️⃣ Future Improvements

Area Planned Enhancement Why It Matters
Token Granularity Issue read‑only or read/write JWTs based on Client-ID (e.g., web-dashboard gets read‑only). Limits the blast radius if a token is compromised.
Pagination Add limit/offset (or cursor‑based) pagination to “get all” endpoints (/sources, /claims, /proofs). Prevents performance degradation as the dataset grows.
Local Testing Environment Provide a Docker‑Compose stack with a seeded DB and a mock Render endpoint (maybe even connect to the actual Render instance from localhost UI?). Add a make dev target to spin up the API, DB, and UI together. Enables rapid UI iteration without pushing to a remote environment.
Fine‑Grained Auditing Log the Client-ID and endpoint usage to a centralized observability platform (e.g., Loki). Improves traceability and helps detect anomalous activity.

Conclusion

Securing the source-score API was the driving force behind a series of architectural upgrades:

  • JWT authentication replaced a brittle API‑key system, providing client‑specific, time‑bound access.
  • Middleware enforcement guarantees that every request is validated against the token’s audience, issuer, and expiration.
  • Database‑level constraints via GORM add a safety net for data integrity.
  • Ingestion pipelines now fetch tokens automatically, eliminating secret leakage risks.
  • Dashboard UI benefits from a shared auth.js module, reducing code duplication.

The result is a secure, maintainable, and scalable ecosystem that can evolve without breaking existing clients. If you’d like to explore the code, the repositories are publicly available:

Feel free to clone and explore them and if you have any suggestions or questions for me, drop them in the comment section.

Top comments (0)