DEV Community

Cover image for iOS Universal Links on AWS Amplify: Debugging a 301 Redirect on the AASA File

iOS Universal Links on AWS Amplify: Debugging a 301 Redirect on the AASA File

Deep linking looks simple from the outside.

A user taps an HTTPS link.

iOS recognises the domain.

The native application opens.

The user lands on the correct screen.

But behind that apparently simple flow, several systems have to agree on exactly how the URL should behave.

Recently, I worked on the infrastructure side of an iOS Universal Links implementation where the configuration initially looked correct.

The association file existed.

The domain was available over HTTPS.

The mobile team knew which domain needed to be associated with the application.

But the Universal Link still wasn't behaving as expected.

The issue turned out not to be in the mobile application's routing logic.

It was an HTTP redirect.

More specifically:

AWS Amplify was returning a 301 for the extensionless path Apple expected us to serve directly.

That small difference between:

The file exists.

and:

The file is served exactly the way the client expects.

was the real problem.

How iOS Universal Links work

Universal Links allow a normal HTTPS link to open a native iOS application instead of Safari.

For that to work, iOS needs to establish a relationship between two sides:

  1. The website.
  2. The iOS application.

On the website side, the domain hosts an:

apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

file, commonly referred to as the AASA file.

On the application side, the app declares the domain through Apple's Associated Domains capability.

Conceptually, the application might declare something like:

applinks:app.example.com
Enter fullscreen mode Exit fullscreen mode

The website then exposes its association file at:

https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

The important detail is the final part:

apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

There is no .json extension in the public URL.

That detail became central to the incident.

My responsibility was the infrastructure side

This was a cross-team feature.

The mobile team owned things such as:

  • Associated Domains configuration.
  • Receiving the incoming Universal Link.
  • Parsing the URL.
  • Routing the user to the correct screen inside the application.

My responsibility on the DevOps side was different.

I needed to make sure the association file was:

  • reachable over HTTPS;
  • available from the expected path;
  • returning the expected content;
  • not being intercepted by frontend routing;
  • externally verifiable.

At first, the file itself looked like the obvious place to investigate.

It wasn't.

The first important clue came from the HTTP response.

The first thing I checked was the HTTP response

Instead of starting with the JSON contents, I checked what the public endpoint was actually returning.

A simple curl request was enough:

curl -s -o /dev/null \
  -w "status=%{http_code} redirects=%{num_redirects} content-type=%{content_type}\n" \
  https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

For this implementation, the response I wanted looked like:

status=200 redirects=0 content-type=application/json
Enter fullscreen mode Exit fullscreen mode

That tells me three important things.

First:

status=200
Enter fullscreen mode Exit fullscreen mode

The resource is being returned successfully.

Second:

redirects=0
Enter fullscreen mode Exit fullscreen mode

The requested URL is being served directly.

Third:

content-type=application/json
Enter fullscreen mode Exit fullscreen mode

The endpoint is returning the expected type of content.

But that wasn't what I was seeing.

The extensionless path was redirecting.

That was the first real clue.

AWS Amplify treated the path differently

The frontend was hosted on AWS Amplify Hosting.

Amplify had no problem serving files that included normal extensions.

For example:

assetlinks.json
Enter fullscreen mode Exit fullscreen mode

worked as expected.

The iOS association file was different.

The public URL had to remain:

/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

with no .json suffix.

In this environment, Amplify treated that extensionless request more like a directory-style path.

Instead of serving the file directly, the request received a redirect.

Conceptually, the behaviour looked like this:

/.well-known/apple-app-site-association
                |
                v
              301
                |
                v
/.well-known/apple-app-site-association/
                |
                v
      frontend fallback / wrong response
Enter fullscreen mode Exit fullscreen mode

That created an interesting situation.

The association file could exist.

The JSON inside it could be valid.

The TLS certificate could be valid.

The application configuration could look correct.

And the overall integration could still fail because the hosting platform was changing how the URL behaved.

The problem was no longer:

Is the file present?

The better question became:

What does an external client actually receive when it requests the exact URL?

That distinction changed the troubleshooting path completely.

The SPA added another layer

There was another important factor.

The frontend was a single-page application.

Like many SPAs, it used a catch-all rewrite so application routes could still load the frontend.

Conceptually, the existing rule looked something like:

/<*> → /index.html
Enter fullscreen mode Exit fullscreen mode

That is useful for application routes such as:

/dashboard
Enter fullscreen mode Exit fullscreen mode

or:

/settings
Enter fullscreen mode Exit fullscreen mode

But it creates a potential problem for infrastructure endpoints under:

/.well-known/
Enter fullscreen mode Exit fullscreen mode

If the special AASA request isn't handled first, the generic SPA rule can intercept it.

So this wasn't just a file-hosting problem.

It was also a rewrite precedence problem.

The fix: separate the public URL from the physical file

The solution was to stop trying to make Amplify serve the extensionless file directly.

Instead, I separated two concepts:

The URL Apple requests

and:

The physical file Amplify serves

The public URL remained:

/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

But I also included a JSON version in the deployed frontend assets:

/.well-known/apple-app-site-association.json
Enter fullscreen mode Exit fullscreen mode

Amplify could serve the .json version cleanly.

Then I added an explicit rewrite from the extensionless path to the JSON file.

Conceptually, the rules looked like this:

[
  {
    "source": "/.well-known/apple-app-site-association",
    "target": "/.well-known/apple-app-site-association.json",
    "status": "200"
  },
  {
    "source": "/<*>",
    "target": "/index.html",
    "status": "404-200"
  }
]
Enter fullscreen mode Exit fullscreen mode

The first rule is the important one:

{
  "source": "/.well-known/apple-app-site-association",
  "target": "/.well-known/apple-app-site-association.json",
  "status": "200"
}
Enter fullscreen mode Exit fullscreen mode

The external client still requests:

/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

But Amplify internally serves:

/.well-known/apple-app-site-association.json
Enter fullscreen mode Exit fullscreen mode

without sending the client through the unwanted redirect.

That gave me the behaviour I wanted:

Client
  |
  | GET /.well-known/apple-app-site-association
  v
AWS Amplify rewrite
  |
  v
/.well-known/apple-app-site-association.json
  |
  v
200 OK
application/json
Enter fullscreen mode Exit fullscreen mode

Rule order mattered

There was one detail that could easily have made the fix appear not to work.

The AASA-specific rule had to be evaluated before the SPA catch-all.

The correct order was:

1. AASA rewrite
2. SPA catch-all
Enter fullscreen mode Exit fullscreen mode

If I reversed them:

1. SPA catch-all
2. AASA rewrite
Enter fullscreen mode Exit fullscreen mode

then the general frontend rule could swallow the request before Amplify ever reached the AASA-specific rule.

That's one of those infrastructure issues that looks obvious after you know the answer.

During an incident, however, it can easily send the investigation in the wrong direction.

You might start debugging Xcode.

Then Flutter.

Then app entitlements.

Then Apple caching.

Meanwhile, the hosting layer is returning the wrong response before any of those pieces even matter.

Why I kept both files

The deployment contained both an extensionless version and a .json version of the association file.

The public endpoint still followed the expected URL structure.

The JSON version gave Amplify a file it could serve predictably.

The rewrite connected the two behaviours.

That made the hosting architecture effectively:

Public request
    |
    v
apple-app-site-association
    |
    | internal 200 rewrite
    v
apple-app-site-association.json
    |
    v
JSON response
Enter fullscreen mode Exit fullscreen mode

Small change.

Big difference.

I didn't trust the deployment result

Once the rewrite was configured and deployed, I didn't consider the problem solved just because the deployment pipeline was green.

I went back to the external URL.

The same verification command was useful again:

curl -s -o /dev/null \
  -w "status=%{http_code} redirects=%{num_redirects} content-type=%{content_type}\n" \
  https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

This time, the important result was:

status=200 redirects=0 content-type=application/json
Enter fullscreen mode Exit fullscreen mode

That was much more valuable than simply knowing that an Amplify build had completed successfully.

A deployment system can tell me:

I successfully deployed the configuration you gave me.

It cannot automatically tell me:

An external client is receiving exactly the response required by this integration.

Those are different questions.

I also checked the actual body:

curl -s \
  https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

That allowed me to verify that the endpoint wasn't accidentally returning:

  • an HTML error page;
  • the SPA's index.html;
  • a redirect response;
  • an empty document;
  • some unrelated asset.

At that point, the infrastructure side had a clean boundary.

Where DevOps responsibility ended

One useful part of this incident was having a clear separation between the infrastructure problem and the application problem.

From the DevOps side, I could validate:

HTTPS
  +
correct path
  +
HTTP 200
  +
zero redirects
  +
JSON response
Enter fullscreen mode Exit fullscreen mode

Once those conditions were satisfied and the association document contained the expected application identifiers, the remaining investigation could move to the mobile side.

The mobile team still needed to ensure that the application:

  • declared the correct Associated Domain;
  • included the required entitlement in the relevant build;
  • received the Universal Link;
  • routed the URL to the correct screen.

This distinction matters when multiple teams are troubleshooting the same feature.

Without a clear boundary, everyone can end up debugging everything.

A useful diagnostic rule became:

curl → 301
Enter fullscreen mode Exit fullscreen mode

Infrastructure problem.

But:

curl → 200
redirects → 0
content-type → application/json
Enter fullscreen mode Exit fullscreen mode

means the infrastructure side is behaving much closer to what we expect, and the investigation can continue further up the stack.

The deeper lesson: integrations fail between systems

The most interesting part of this incident wasn't the rewrite rule itself.

The rule was small.

The interesting part was that every major component could appear individually correct.

The frontend was deployed.

The file existed.

The JSON was valid.

HTTPS worked.

The app configuration could look reasonable.

AWS Amplify was operating according to its routing behaviour.

And yet the integration was still broken.

Why?

Because integrations fail at the boundaries between systems.

Apple expected one URL behaviour.

Amplify produced another.

The SPA introduced another routing layer.

The application added another configuration layer.

None of those systems necessarily had to be completely broken for the feature to fail.

They only had to disagree.

What this incident reinforced for me

There are several lessons I'll carry forward from this one.

1. Verify the public response, not just the deployed file

Seeing a file inside a repository or build artifact doesn't prove that clients can retrieve it correctly.

Always test the actual public endpoint.

2. HTTP behaviour is part of configuration

When dealing with platform-verification endpoints, don't inspect only the file contents.

Inspect:

HTTP status
redirects
Content-Type
response body
Enter fullscreen mode Exit fullscreen mode

The transport behaviour can be just as important as the JSON itself.

3. .well-known endpoints deserve special attention

Endpoints under:

/.well-known/
Enter fullscreen mode Exit fullscreen mode

often exist for machine-to-machine verification.

That means browsers can give you a misleading sense that things are fine.

Use tools such as:

curl
Enter fullscreen mode Exit fullscreen mode

to inspect the actual HTTP behaviour.

4. SPA catch-all rules can affect infrastructure endpoints

Rules such as:

/<*> → /index.html
Enter fullscreen mode Exit fullscreen mode

are useful for frontend routing.

But they can interfere with files and verification endpoints if more specific rules don't appear first.

Rule precedence matters.

5. A successful deployment is not the same as a verified integration

This lesson keeps appearing in different forms.

A deployment pipeline tells you whether the deployment completed.

It doesn't necessarily tell you whether the external system consuming that deployment sees the correct result.

Post-deployment verification matters.

The troubleshooting sequence I would use next time

If I encountered a Universal Links issue again, I would start from the infrastructure boundary before moving deeper into the mobile application.

First:

curl -I https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

Then check the response in more detail:

curl -s -o /dev/null \
  -w "status=%{http_code} redirects=%{num_redirects} content-type=%{content_type}\n" \
  https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

Then inspect the actual document:

curl -s \
  https://app.example.com/.well-known/apple-app-site-association
Enter fullscreen mode Exit fullscreen mode

From there, I would ask:

Does the endpoint return 200?

Is it redirecting?

Is the response actually JSON?

Is the SPA catch-all intercepting the request?

Is the hosting platform treating the path as a directory?

Does the association file contain the correct app identifiers?

Only after answering those questions would I move deeper into the iOS application configuration.

The bigger lesson

The original issue looked like an iOS deep-linking problem.

But the decisive clue came from HTTP.

Not Xcode.

Not Flutter.

Not the app router.

Not the AWS deployment status.

Just:

301
Enter fullscreen mode Exit fullscreen mode

That one response changed the direction of the investigation.

The eventual fix was relatively small:

extensionless Apple URL
        ↓
Amplify 200 rewrite
        ↓
.json association file
        ↓
200 OK
application/json
Enter fullscreen mode Exit fullscreen mode

But understanding why that rewrite was necessary required looking beyond the application itself.

That's one of the things I appreciate about DevOps work.

Sometimes the visible failure happens in one layer, while the real cause lives somewhere completely different.

And sometimes the most useful debugging tool in the entire incident is simply:

curl
Enter fullscreen mode Exit fullscreen mode

Have you ever debugged a Universal Links, App Links, OAuth callback, webhook, or .well-known endpoint where the real problem turned out to be an unexpected redirect or rewrite?

I'd be interested to hear what caused it.

Top comments (0)