Back to Journal

The redirect that ate our credentials

Listen to this articleAI narration
0:00 / 0:00

Tuesday morning. Support queue exploding. Every single user getting errors when downloading invoices. The error message from Fortnox: "Unable to log in. Access token or client secret missing." Code 2000311.

First instinct: token expired. Check the database. Token exists, valid = true, expires in 45 minutes. JWT decodes correctly, shows all the right scopes. Client ID matches our SSM parameters. Everything looks perfect.

The Initial Investigation

CloudWatch logs paint a grim picture. Every request to the Fortnox invoice preview endpoint returns 403:

{
  "ErrorInformation": {
    "Error": 1,
    "Message": "Unable to log in. Access token or client secret missing.",
    "Code": 2000311
  }
}

The token refresh mechanism isn't triggering because we're getting 403, not 401. The code only retries on 401:

// HttpApi.php - Token refresh only on 401
$response = $this->client->request($method, $path, $options);
if (401 === $response->getStatusCode()) {
    $options['headers']['Authorization'] = 'Bearer '.$this->accessTokenManager->getRefreshedAccessToken();
    return $this->client->request($method, $path, $options);
}
return $response;

Re-running the OAuth flow produces "Fortnox connection completed successfully!" — new token in database, still 403 on every API call.

The Postman Test

Desperation move: copy the exact token from the database, paste into Postman, hit the Fortnox API directly.

curl -X GET "https://api.fortnox.se/3/invoices?limit=1" \
  -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." \
  -H "Content-Type: application/json"

200 OK. Invoice data returns perfectly. The token works. Fortnox API works. Something between our application and Fortnox is broken.

Token Validation: Direct vs ApplicationDirect Request (Postman)curl → api.fortnox.seAuthorization: Bearer [token]200 OKApplication RequestSymfony HTTP Client → apps.fortnox.seAuthorization: Bearer [token]403 ForbiddenSame token, different resultsWhat's different about the application's request path?

The Redirect Discovery

Back to CloudWatch. This time, searching for the full request lifecycle. There it is:

Request: "GET https://apps.fortnox.se/3/invoices/73921/preview"
Redirecting: "301 https://api.fortnox.se/3/invoices/73921/preview"
Response: "403 https://api.fortnox.se/3/invoices/73921/preview"

Our HTTP client config uses apps.fortnox.se as the base URI. Fortnox is now returning a 301 redirect to api.fortnox.se. The request follows the redirect — but arrives without authentication.

Checking yesterday's logs for comparison:

Response: "200 https://apps.fortnox.se/3/invoices/73812"

No redirect. Direct 200 from apps.fortnox.se. Fortnox changed their infrastructure routing overnight.

The Redirect Authentication Leak1. Initial Requestapps.fortnox.se+ Authorization header2. Fortnox Returns301 RedirectLocation: api.fortnox.se3. Symfony Followsapi.fortnox.se- Authorization STRIPPEDWhy Headers Are StrippedSymfony HTTP Client follows RFC 7231 security guidelines:"Authorization and Cookie headers MUST NOT follow except for the initial host name"This prevents credential leakage when redirecting to different domains.apps.fortnox.se → api.fortnox.se = different host = headers dropped

The Security Feature Working Against Us

Symfony's HTTP client strips Authorization headers on cross-origin redirects. This is intentional — documented behavior to prevent credential leakage to third-party domains. From the Symfony GitHub issue tracker:

"This header is stripped from cross-origin redirects."

The irony: a security feature protecting us from credential theft is breaking our legitimate API integration. Fortnox's infrastructure change triggered Symfony's protective behavior.

The Fix

Two changes required. First, update the HTTP client base URI to skip the redirect entirely:

# config/packages/http_client.yaml
http_client.fortnox:
    base_uri: 'https://api.fortnox.se'  # was: apps.fortnox.se

Second, preserve the OAuth token endpoint at the original domain (OAuth lives on apps.fortnox.se):

// AccessTokenManager.php
return $this->httpClientFortnox->request(
    'POST',
    'https://apps.fortnox.se/oauth-v1/token',  // absolute URL
    [...]
);

API calls now go directly to api.fortnox.se. No redirect. Authorization header preserved. OAuth refresh still hits the correct endpoint.

The Invisible Infrastructure Change

No announcement from Fortnox. No deprecation warning. No changelog entry. One day apps.fortnox.se serves API responses directly; the next day it redirects to api.fortnox.se. Our integration breaks silently.

The logs showed nothing obviously wrong — requests going out, responses coming back. The 403 error message pointed at authentication, not routing. Only by comparing the full request chain between working (yesterday) and broken (today) did the redirect appear.

Lessons

Third-party API integrations are fragile. Infrastructure changes on their side can break your integration without touching your code. The symptoms often point in the wrong direction — we spent hours verifying token validity when the token was never the problem.

When debugging API failures: trace the full request path. Check for redirects. Compare against a direct curl/Postman request to isolate whether the issue is in your HTTP client behavior or the API itself. The five minutes spent on that Postman test saved hours of wrong-direction debugging.