{"id":64,"date":"2026-07-19T17:05:00","date_gmt":"2026-07-19T14:05:00","guid":{"rendered":"https:\/\/fsaeed.blog\/?p=64"},"modified":"2026-10-31T00:18:50","modified_gmt":"2026-10-30T21:18:50","slug":"flask-link-shortener","status":"publish","type":"post","link":"https:\/\/fsaeed.blog\/index.php\/2026\/07\/19\/flask-link-shortener\/","title":{"rendered":"Build Your Own Link Shortener with Flask"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Hello, tech enthusiasts! \ud83d\udc4b<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your team sends long links all the time: in SMS messages, in printed flyers, in emails. A link like <code><a href=\"https:\/\/www.example.com\/offers\/summer\/2026\/product-page?ref=flyer\">https:\/\/www.example.com\/offers\/summer\/2026\/product-page?ref=flyer<\/a><\/code> is hard to type and looks messy. A short link like <code><a href=\"https:\/\/go.example.com\/Xy7a9Q\">https:\/\/go.example.com\/Xy7a9Q<\/a><\/code> is much nicer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An online service could do it, but then someone else holds your links and click numbers. Today we build our own in one Python file!<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What you need<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Python 3.9 or newer (<a href=\"https:\/\/www.python.org\">https:\/\/www.python.org<\/a>)<\/li>\n\n\n<li>Flask, a small web framework: <code>pip install flask<\/code><\/li>\n\n\n<li>SQLite, a database that lives in a single file (it is already part of Python)<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A web framework is like a ready kitchen: the stove and the pots are there, you only cook the food.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: The plan<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Our app does four things:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Creates a short code for a long link.<\/li>\n\n\n<li>Sends visitors from the short link to the long link (a <strong>redirect<\/strong>). We use code <strong>302<\/strong>, &#8220;temporary move&#8221;, so browsers do not remember it forever.<\/li>\n\n\n<li>Counts every click.<\/li>\n\n\n<li>Has a small admin page, protected by a password.<\/li>\n\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">One safety rule: if any link can be shortened, spammers will use your domain to hide bad websites. So we only allow targets on <strong>our own domains<\/strong>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: The code<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a file called <code>app.py<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hmac\nimport os\nimport secrets\nimport sqlite3\nfrom urllib.parse import urlparse\n\nfrom flask import Flask, Response, abort, g, redirect, request\nfrom markupsafe import escape\n\napp = Flask(__name__)\nDB_FILE = \"links.db\"\n# Only links to these sites can be shortened\nALLOWED_HOSTS = {\"example.com\", \"www.example.com\", \"shop.example.com\"}\nADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\", \"\")\n\n\ndef init_db():\n    with sqlite3.connect(DB_FILE) as conn:\n        conn.execute(\"\"\"CREATE TABLE IF NOT EXISTS links (\n            code TEXT PRIMARY KEY,\n            target TEXT NOT NULL,\n            clicks INTEGER NOT NULL DEFAULT 0)\"\"\")\n\n\ndef db():\n    # One database connection per request\n    if \"db\" not in g:\n        g.db = sqlite3.connect(DB_FILE)\n        g.db.row_factory = sqlite3.Row\n    return g.db\n\n\n@app.teardown_appcontext\ndef close_db(error):\n    conn = g.pop(\"db\", None)\n    if conn is not None:\n        conn.close()\n\n\ndef is_admin():\n    auth = request.authorization\n    if not ADMIN_PASSWORD or auth is None or auth.password is None:\n        return False\n    # compare_digest is safe against timing attacks\n    return hmac.compare_digest(auth.password.encode(), ADMIN_PASSWORD.encode())\n\n\ndef target_ok(url):\n    parts = urlparse(url)\n    return parts.scheme in (\"http\", \"https\") and parts.hostname in ALLOWED_HOSTS\n\n\n@app.route(\"\/admin\", methods=[\"GET\", \"POST\"])\ndef admin():\n    if not is_admin():\n        return Response(\"Login required\", 401,\n                        {\"WWW-Authenticate\": 'Basic realm=\"admin\"'})\n    message = \"\"\n    if request.method == \"POST\":\n        target = request.form.get(\"target\", \"\").strip()\n        if not target_ok(target):\n            message = \"Only links to our own websites are allowed.\"\n        else:\n            for _ in range(5):  # try again if the random code already exists\n                code = secrets.token_urlsafe(4)\n                try:\n                    db().execute(\"INSERT INTO links (code, target) VALUES (?, ?)\",\n                                 (code, target))\n                    db().commit()\n                    message = f\"Short link: {request.host_url}{code}\"\n                    break\n                except sqlite3.IntegrityError:\n                    continue\n    rows = db().execute(\n        \"SELECT code, target, clicks FROM links ORDER BY clicks DESC\").fetchall()\n    items = \"\".join(\n        f\"&lt;li&gt;{escape(r['code'])} &amp;rarr; {escape(r['target'])} ({r['clicks']} clicks)&lt;\/li&gt;\"\n        for r in rows)\n    return f\"\"\"&lt;h1&gt;Link shortener&lt;\/h1&gt;\n&lt;p&gt;{escape(message)}&lt;\/p&gt;\n&lt;form method=\"post\"&gt;\n  &lt;input name=\"target\" size=\"60\" placeholder=\"https:\/\/www.example.com\/page\"&gt;\n  &lt;button&gt;Shorten&lt;\/button&gt;\n&lt;\/form&gt;\n&lt;ul&gt;{items}&lt;\/ul&gt;\"\"\"\n\n\n@app.route(\"\/&lt;code&gt;\")\ndef follow(code):\n    row = db().execute(\"SELECT target FROM links WHERE code = ?\", (code,)).fetchone()\n    if row is None:\n        abort(404)\n    db().execute(\"UPDATE links SET clicks = clicks + 1 WHERE code = ?\", (code,))\n    db().commit()\n    return redirect(row[\"target\"], code=302)\n\n\ninit_db()\n\nif __name__ == \"__main__\":\n    app.run()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A few notes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>secrets.token_urlsafe(4)<\/code> makes a random code of about 6 letters and numbers, like <code>Xy7a9Q<\/code>.<\/li>\n\n\n<li>The <code>?<\/code> marks in the SQL keep the database safe from &#8220;SQL injection&#8221;, a trick where an attacker hides commands inside the text.<\/li>\n\n\n<li><code>escape()<\/code> stops anyone from putting HTML or scripts into your admin page.<\/li>\n\n\n<li><code>urlparse(...).hostname<\/code> reads the real site name. So a tricky link like <code><a href=\"https:\/\/example.com@bad-site.test\">https:\/\/example.com@bad-site.test<\/a><\/code> is blocked, because its real host is <code>bad-site.test<\/code>.<\/li>\n\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: Run it<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Set a long password, then start the app:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export ADMIN_PASSWORD='choose-a-long-random-password'\npython app.py<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open <code><a href=\"http:\/\/127.0.0.1:5000\/admin\">http:\/\/127.0.0.1:5000\/admin<\/a><\/code> in your browser. It asks for a username and password. Type any username and your password. Paste a long link, click <strong>Shorten<\/strong>, and try the short link. Then refresh the admin page and see the click count go up.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4: Deploy it<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The built-in Flask server is only for testing. For real use:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Run the app with <strong>gunicorn<\/strong>: <code>pip install gunicorn<\/code>, then <code>gunicorn -w 2 -b 127.0.0.1:8000 app:app<\/code>.<\/li>\n\n\n<li>Put a web server like nginx in front, with <strong>HTTPS<\/strong>. This is a must. The admin password is sent with every request, and without HTTPS anyone on the network could read it.<\/li>\n\n\n<li>Use a short domain or subdomain, like <code>go.example.com<\/code>.<\/li>\n\n\n<li>Back up <code>links.db<\/code> regularly. It holds all your links.<\/li>\n\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Common problems<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The admin page keeps asking for the password.<\/strong> The <code>ADMIN_PASSWORD<\/code> variable is empty in the terminal where the app runs. Set it again in the same window.<\/li>\n\n\n<li><strong>&#8220;Only links to our own websites&#8221; for a correct link.<\/strong> Add the exact host name to <code>ALLOWED_HOSTS<\/code>, for example <code>shop.example.com<\/code>.<\/li>\n\n\n<li><strong>&#8220;database is locked&#8221; errors.<\/strong> Many writers at once can cause this. Keep the number of workers small.<\/li>\n\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Wrap-up<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In about 100 lines you have short links, click counts, a protected admin page and protection against abuse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next idea: add a column for the date of each click, and make a small weekly report of the top links for your marketing team. \ud83d\ude80<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Make short links for your own website, count the clicks, and keep spammers out.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[28,12,18,65],"class_list":["post-64","post","type-post","status-publish","format-standard","hentry","category-uncategorized","tag-flask","tag-python","tag-sqlite","tag-web"],"_links":{"self":[{"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/posts\/64","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/comments?post=64"}],"version-history":[{"count":1,"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/posts\/64\/revisions"}],"predecessor-version":[{"id":69,"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/posts\/64\/revisions\/69"}],"wp:attachment":[{"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/media?parent=64"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/categories?post=64"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/fsaeed.blog\/index.php\/wp-json\/wp\/v2\/tags?post=64"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}