HTTP Redirects with Azure Functions
An effective URL shortener on a budget
2025-11-20
Our analytics team publishes several reports in PowerBI and they post these links in various places on public and internal web services. Periodically, they have to rewrite these reports and the publication URLs change. This required updating the links in various location. They inquired about a URL shortening/redirect service as an alternative option. Ideally, it would allow them to create a shortcut and adjust the target down the road. We don't subscribe to any public URL shorteners, and while our marketing team provides a URL redirect service, it isn't user editable and the turnaround could be several days. This was impractical for our analytics team, as many rely on being able to review their reports on a daily basis.
This seemed more broadly applicable, so I started designing an app with general usage in mind. It also seemed like a good candidate for an Azure Functions app. The frequency of access is minimal, and the core functionality is a single redirect. Azure Function apps are free for the first 1,000,000 executions per month, and we don't draw anywhere near that kind of volume. I hadn't experimented with function apps in Azure, but I had worked with AWS functions in a previous role and was fairly confident it was the right service. The task started out pretty simple:
Main Purpose
- Redirect from one URL to another
Secondary Requirements
- Allow authenticated users to create and edit redirects
Proof of Concept
With this in mind, I began to work on a proof of concept. To store the redirect objects, I set up a container in Azure Cosmos DB. If you're not familiar with Cosmos, it's a document data store much like Mongo DB or AWS Dynamo. It scales easily, you pay for what you use, and it doesn't require complex schema definitions. My initial redirect definition looked like this:
{
"id": "patches",
"target": "https://powerbi.com/really/long/report/url here",
"owner": "user1@lab.local",
"description": "Patch Reporting",
"last_updated": "2025-11-14T01:11:57.313001Z",
}
This definition really didn't change much by the time I reached the final product. I included a couple of additional fields as the scope expanded, but this largely supported the main purpose of the service.
The core function is a dynamic route defined below. This takes the requested path and performs a lookup for a matching ID in cosmos. Assuming it finds one, it will fetch the URL stored in the target value and respond with a 302 redirect to route the client to the target location.
import azure.functions as func
from azure.cosmos import CosmosClient, exceptions
def get_cosmos_container():
"""Get Cosmos DB container client"""
client = CosmosClient(COSMOS_URL, credential=COSMOS_KEY)
database = client.get_database_client(DATABASE_NAME)
return database.get_container_client(CONTAINER_NAME)
@app.route(route="{link_id}", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS)
def redirect_link(req: func.HttpRequest) -> func.HttpResponse:
link_id = req.route_params.get("link_id")
if not link_id or link_id.startswith("trusted"):
return func.HttpResponse("Not found", status_code=404)
try:
container = get_cosmos_container()
item = container.read_item(item=link_id, partition_key=link_id)
target = item.get("target")
if not target:
return func.HttpResponse("Redirect target not defined", status_code=404)
else:
return func.HttpResponse(status_code=302, headers={"Location": target})
except exceptions.CosmosResourceNotFoundError:
return func.HttpResponse("Redirect not found", status_code=404)
except Exception as e:
logging.error(f"Failed to find redirect for {link_id}. Exception: {e}")
return func.HttpResponse("Internal server error", status_code=500)
This function did what we needed. While our analytics team would be comfortable creating and editing Cosmos documents, this didn't fit the general purpose bill. I needed to come up with a user interface that allowed for CRUD redirect management. This is where the project jumped from fewer than 50 lines of code to almost 1K lines of code. Support for authentication in Function Apps is fairly straightforward. The built-in method creates an enterprise app and app registration complete with redirect URLs and basic API permissions. It also adds the client secret into the environment variables in the function app automatically. As an admin, the only thing I had to do in the Azure portal beyond that was grant admin consent so users aren't prompted to do it when they first log in.
While you can enforce authentication at the function level during setup, no all of my routes require authentication. The majority of its use would be public redirects. To accommodate this, I used the helper function below to require logon.
@app.route(route="trusted/redirects", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS)
def trusted_list_redirects(req: func.HttpRequest) -> func.HttpResponse:
try:
user_email, auth_error = require_auth(req)
if auth_error:
return auth_error
result = get_user_redirects_html(req, user_email)
return result
except Exception as e:
logging.error(f"Error in trusted_list_redirects: {e}")
error_html = f"""
<!DOCTYPE html>
<html>
<head><title>Error</title></head>
<body>
<h1>Internal Server Error</h1>
<p><a href="/trusted/redirects">Try Again</a></p>
</body>
</html>
"""
return func.HttpResponse(
error_html,
status_code=500,
mimetype="text/html"
)
```
After creating a PoC and talking with my manager, the scope began to expand a bit.
Expanded Scope
- Administrative access - Admins can create and manage all redirects
- Audit/Operation logs
- Display deleted records
- Allow restoring records (as long as they don't exist in the live redirect list)
- Allow additional link owners who have the same access
- Display times in local time
- Authorization failure page with support contact
- Custom domain name (redirect.)
- Redirect from redirects. to redirect.
- Add a descriptive subtitle
- Add a help menu
- Add branding (logo/favicon)
- Validate URLs client and server-side
- Validate naming client and server-side
- Permit case-insensitive matching
I've writtent plenty of flask apps, so my first thought was to run this in an app service