TutorialsMetals API › Authentication

Metals API · Section 2 of 3

Authentication: JWT and Insomnia, Step by Step

Every endpoint in this API needs to know who's asking, and write endpoints need to know whether that person is an Admin. This page starts with what a JWT actually is (no prior knowledge assumed), then walks through Insomnia click by click: registering or logging in, finding the token in the response, and attaching it to every request that follows.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
File → metals_api/auth.py Token format → JWT (JSON Web Token) Library → PyJWT, algorithm HS256 Header used → Authorization: Bearer <token> Default lifetime → 60 minutes Decode one yourself → 3 demo tokens for jwt.io

Purpose of this page

Explain what a JWT is well enough to use one confidently, then get you from “no token” to “successfully calling a protected endpoint in Insomnia” without skipping a step.

Codey typing on a laptop
new to JWTs? start here

What is a JWT, in plain terms? the wristband analogy

The problem it solvesHTTP has no memory

Each HTTP request is independent — the server doesn't remember the previous request you made, even a second ago. So if logging in required typing your username and password on every single request, that would be slow, and it would mean sending your password over the network constantly. A JWT (JSON Web Token) solves this: you log in once, the server hands you back a token, and you show that token on every request after that instead of your password.

Think of a wristband at a concert: you show ID once at the gate, and get a wristband. For the rest of the night, security only checks the wristband — they don't ask for your ID again at every door.

The three parts of a tokenheader.payload.signature

A JWT is just a string with two dots in it, like eyJhbGci... . eyJzdWIi... . SflKxwRJ.... Each of the three sections means something specific:

Header
Says which algorithm was used to sign the token. This API uses HS256.
Payload
The actual claims — the data about you. See below for exactly what this API puts here.
Signature
A cryptographic stamp, computed from the header, the payload, and a secret key only the server knows (JWT_SECRET_KEY in .env). If anyone edits the payload, the signature no longer matches, and the server rejects the token.
Signed, not encryptedan important distinction

The header and payload are only Base64-encoded, not encrypted — anyone can paste your token into jwt.io and read exactly what's inside it. What the signature guarantees is that nobody can change it without the server noticing, not that its contents are secret. That's why this API's payload only ever contains a user id, username, and role names — never a password or anything sensitive.

Don't take our word for it — the next section has three real tokens to paste in and decode yourself.

What's actually in this API's payloadmetals_api/services/auth_service.py
{
  "sub": "1",
  "username": "admin",
  "roles": ["Admin"],
  "iat": 1768467600,
  "exp": 1768471200
}

sub (“subject”) is the user id, as a string. roles is a list of role names — this is exactly what lets the server decide, later, whether you're allowed to create or delete data. iat (“issued at”) and exp (“expires at”) are timestamps; once the current time passes exp, the token stops working and you have to log in again.

Try it yourself on jwt.io hands on

Reading about tokens only goes so far. jwt.io is a free debugger that splits any token into its three parts and decodes them in front of you. Below are three real, working tokens issued in this API's exact format — paste each one into the Encoded box at jwt.io and read what comes back.

Codey holding a magnifying glass
decode them yourself
How to use themfour steps
  1. Open jwt.io and find the debugger's Encoded box.
  2. Paste one token in, replacing whatever is already there. The Decoded panel updates instantly — header on top, payload below it.
  3. To check the signature too, put the demo secret below into the verification box. jwt.io will tell you whether the signature is valid.
  4. Then try the experiments at the end of this section.

Never paste a real token from a real system into any website. A token is a credential — pasting it somewhere is like pasting a password. These three are safe precisely because they're throwaway demo tokens signed with a publicly-known secret.

The demo secretfor signature verification
metals-tutorial-demo-secret-not-for-real-use

All three tokens below are signed with this string using HS256, the same algorithm this API uses. Paste it into jwt.io's signature box and you'll see the signature verify. Change a single character of it and verification fails — which is exactly the check metals_api/auth.py performs on every request.

A real deployment's secret is a long random value kept in JWT_SECRET_KEY and never shared. This one is published in a tutorial, so treat any token signed with it as worthless.

Token 1 — an Admin paste this first

token 1 — admin, valideyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbIkFkbWluIl0sImlhdCI6MTc2ODQ2NzYwMCwiZXhwIjoyMDUxMjIyNDAwfQ.kjEQ-8JG0x8dGTdcy7YGaA8JQyymUVMMsKu21zYzpM8
What you should seedecoded payload
{
  "sub": "1",
  "username": "admin",
  "roles": ["Admin"],
  "iat": 1768467600,
  "exp": 2051222400
}

This is the token the admin seed account would receive from POST /api/auth/login. Notice that roles contains "Admin" — that one array is what lets this token create, update, and delete catalog data.

Notice also what isn't there: no password, no password hash, no email. The payload is readable by anyone, so it only carries what's safe to be read.

Token 2 — a Customer spot the difference

token 2 — customer, valideyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwidXNlcm5hbWUiOiJjdXN0b21lciIsInJvbGVzIjpbIkN1c3RvbWVyIl0sImlhdCI6MTc2ODQ2NzYwMCwiZXhwIjoyMDUxMjIyNDAwfQ.VCjDaelsV6mvDFoeiNjyjA5PnAlNwt1tdkclDCQ-bM0
What you should seedecoded payload
{
  "sub": "2",
  "username": "customer",
  "roles": ["Customer"],
  "iat": 1768467600,
  "exp": 2051222400
}

Structurally identical to token 1 — same claims, same algorithm, same validity. The only meaningful difference is "roles": ["Customer"]. Send this token to POST /api/elements and the API answers 403 Forbidden; send token 1 and it succeeds.

That contrast is the whole of authorization in one line of JSON. Authentication (“who are you”) succeeded for both tokens; authorization (“may you do this”) is decided by the roles claim.

Token 3 — an expired Admin valid signature, dead token

token 3 — admin, expiredeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbIkFkbWluIl0sImlhdCI6MTc2ODQ2NzYwMCwiZXhwIjoxNzY4NDcxMjAwfQ.lX-Etp7KONSl90ppInMriR-0tZ1Nj16HwbaPhG9OYFA
What you should seedecoded payload
{
  "sub": "1",
  "username": "admin",
  "roles": ["Admin"],
  "iat": 1768467600,
  "exp": 1768471200
}

Identical to token 1 except for exp. Here iat is 15 January 2026 at 09:00 UTC and exp is 10:00 UTC the same day — a 60-minute window, matching this API's real JWT_EXPIRATION_MINUTES default. That hour is long past, so the API rejects this token with 401 Invalid or expired access token.

The important subtlety: its signature is still perfectly valid. jwt.io will confirm that, and may also flag the token as expired. Nothing was tampered with — the token is simply past its own stated deadline, and the server is the thing that enforces it.

Experiments worth runningfive minutes each
  1. Promote yourself. With token 2 loaded, edit the decoded payload to say "roles": ["Admin"]. Watch the encoded token change — and the signature stop verifying. This is exactly why a token's contents can't be trusted without checking the signature.
  2. Re-sign it. Now put the demo secret back in. jwt.io recomputes a valid signature, and you have a working Admin token. This is what leaking a signing key costs you — and why JWT_SECRET_KEY is treated as a secret.
  3. Compare the timestamps. Paste 1768467600 and 1768471200 into any Unix-timestamp converter. They're plain seconds since 1970 — no encryption, no obfuscation.
  4. Break the header. Change "alg" from HS256 to none. Some naive libraries historically accepted this; this API doesn't, because it passes algorithms=["HS256"] explicitly when decoding.
  5. Get a real one. Log in through the API (below) and decode your own token. Same five claims, your own user id, and an exp exactly 60 minutes ahead.

One honest caveat: tokens 1 and 2 carry an exp of 2051222400 — 1 January 2035 — so they keep working as teaching material for years. Real tokens from this API last 60 minutes, like token 3. If you ever see a production token with a multi-year expiry, that's a finding, not a convenience.

How this API issues and checks a token metals_api/auth.py

Two small decorators, used together on almost every route, cover the entire login and permission system.

Codey inspecting something with a magnifying glass
two decorators, every protected route
Issuing a tokenPOST /api/auth/register or /login

auth_service.create_access_token(user) builds the payload above and signs it with JWT_SECRET_KEY from .env. The route wraps it in a JSON response alongside token_type: "Bearer" and how many seconds until it expires.

@require_authis anyone logged in?
metals_api/auth.pydef require_auth(view):
    @wraps(view)
    def wrapped(*args, **kwargs):
        authorization = request.headers.get("Authorization", "")
        scheme, _, token = authorization.partition(" ")
        if scheme.lower() != "bearer" or not token:
            return jsonify({"error": "Authentication required."}), 401

        try:
            g.current_user = jwt.decode(
                token, current_app.config["JWT_SECRET_KEY"], algorithms=["HS256"],
            )
        except jwt.InvalidTokenError:
            return jsonify({"error": "Invalid or expired access token."}), 401

        return view(*args, **kwargs)
    return wrapped

Reads the Authorization header, requires it to say Bearer <token>, and decodes the token — jwt.decode is what actually re-checks the signature. If it decodes successfully, the payload is stashed on Flask's per-request g object as g.current_user so the rest of the request can read it.

@require_roles("Admin")are they allowed to do this?
metals_api/auth.pydef require_roles(*required_roles: str):
    def decorator(view):
        @wraps(view)
        def wrapped(*args, **kwargs):
            current_roles = set(g.current_user.get("roles", []))
            if not current_roles.intersection(required_roles):
                return jsonify({"error": "You do not have permission..."}), 403
            return view(*args, **kwargs)
        return wrapped
    return decorator

Reads the roles require_auth already put on g.current_user and checks for overlap with whatever roles were passed in, like "Admin". No overlap means 403 Forbidden.

Why the decorator order mattersa subtle but important detail
@element_blueprint.post("")
@require_auth
@require_roles("Admin")
def create_element():
    ...

Stacked decorators apply from the bottom up, but that means the topmost one is the outermost wrapper — the first thing to actually run when a request comes in. Here, require_auth runs first, decodes the token, and sets g.current_user. Only then does require_roles run and read it. If the order were reversed, require_roles would run before g.current_user exists and crash. Every write route in this project keeps @require_auth directly under the route decorator, with @require_roles(...) below it, for exactly this reason.

Setting up Insomnia step by step

The rest of this page assumes Insomnia is installed and the API is running locally at http://localhost:5000 (see the Database tutorial to get the database running first).

Codey holding up a sticky note
a few clicks before your first request
  1. Open Insomnia and create a new Request Collection (bottom of the sidebar, or CreateRequest Collection). Name it something like Metals API.
  2. Optional, but worth doing: click the environment dropdown near the top of the sidebar (usually labeled No Environment), choose Manage Environments, and add a JSON value { "base_url": "http://localhost:5000" }. You can then type {{ _.base_url }} in any request URL instead of typing the full address every time.
  3. Inside the collection, click New HTTP Request. You'll repeat this for each endpoint below — give each one a clear name (e.g. Register, Login, List Elements).

Register or log in getting your first token

You have two options: log in as one of the two accounts the database already seeds, or register a brand-new one. Either way, the response has the same shape.

Codey pointing to the right
same response shape, either way
Option A — log in with a seed accountfastest
  1. Set the request method dropdown (left of the URL bar) to POST.
  2. Enter the URL: http://localhost:5000/api/auth/login.
  3. Click the Body tab, choose JSON, and enter:
    { "username": "admin", "password": "password" }
    (or "customer" instead of "admin" — see the seed accounts table for both.)
  4. Click Send.
Option B — register a new accountPOST /api/auth/register
  1. New request, method POST, URL http://localhost:5000/api/auth/register.
  2. Body tab → JSON:
    { "username": "sample_student", "password": "SomeSecurePassword1" }
  3. Click Send.

Every newly registered account is automatically given the Customer role — there's no signup field for choosing Admin. To test Admin-only endpoints, log in as the seeded admin account instead.

The response you should see200 (login) or 201 (register)
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "user": {
    "user_id": 1,
    "username": "admin",
    "roles": ["Admin"]
  }
}

Copy the full access_token string (click into the value in Insomnia's response pane and select all — it's long, don't truncate it). That's the token you'll attach to every other request.

Attaching the token to a request the Bearer header

Every other endpoint reads the token from the Authorization HTTP header, in the exact form Bearer <token> (the word “Bearer”, one space, then the token). Insomnia gives you two ways to set this; the first is simplest, the second saves retyping it on every request.

Codey typing on a laptop
one header, every protected request
Per-request, using the Auth tabsimplest
  1. Open (or create) a request — e.g. GET http://localhost:5000/api/elements.
  2. Click the Auth tab, just below the URL bar.
  3. Choose Bearer Token from the auth-type dropdown.
  4. Paste your copied token into the Token field. Leave Prefix as Bearer.
  5. Click Send. Insomnia now adds the header for you: Authorization: Bearer <your token>.
Setting it once for the whole collectionsaves repeating it
  1. In your environment (from setup above), add a second value: "token": "<paste your access_token here>".
  2. Click your collection's folder in the sidebar (not an individual request), open its Auth tab, and choose Bearer Token.
  3. In the Token field, type {{ _.token }} instead of pasting the raw value.
  4. On each individual request, set its own Auth tab to Inherit from parent (the default for new requests). Every request in the collection now sends the same token automatically.

When the token expires (see common errors below), log in again and update just the one environment value — every request picks up the new token without being edited individually.

What's actually happening underneaththe raw HTTP header

However you set it in Insomnia, the request that actually goes over the network has one extra header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

That's the exact string metals_api/auth.py's require_auth reads back out with request.headers.get("Authorization") on the server. If you ever need to call this API from curl or code instead of Insomnia, this is the one header you need to add by hand.

Verifying it worked two quick checks

A logged-in-only endpoint and an Admin-only endpoint, so you can confirm both layers of the permission system.

Codey giving a thumbs up
two checks, two layers
Check 1 — are you logged in?GET /api/auth/me

With the Bearer token attached, send GET http://localhost:5000/api/auth/me. You should get back 200 with your user_id, username, and roles — read straight back out of the token, proving the round trip works.

Check 2 — do you have Admin?POST /api/elements

Send POST http://localhost:5000/api/elements with a small JSON body (see Section 1 for the shape). Logged in as admin, expect 201 Created. Logged in as customer (or your own newly registered account), expect 403 Forbidden — that's the require_roles("Admin") check doing its job.

Common errors and how to fix them

What you seeLikely causeHow to fix it
401 “Authentication required.”No Authorization header was sent at all, or it didn't start with Bearer .Check the request's Auth tab is set to Bearer Token (not No Auth), and that a token value is actually present.
401 “Invalid or expired access token.”The token expired (default lifetime is 60 minutes), was copied incompletely, or the server's JWT_SECRET_KEY changed (e.g. the API restarted with a different .env) since the token was issued.Send the login request again to get a fresh token, and make sure you copied the entire access_token value.
403 “You do not have permission to perform this action.”You're logged in, but as a Customer, and this endpoint requires Admin.Log in as the seeded admin account instead (see seed accounts).
400 “That username is already in use.”You tried to register a username that already exists — including admin or customer, which are already seeded.Pick a different username, or just log in instead of registering.
400 on login/register with no useful detailThe request body wasn't valid JSON, or the Body tab wasn't set to JSON in Insomnia.Double-check the Body tab shows JSON (not Form URL Encoded, and not empty), and that the JSON has both username and password.
Codey giving a thumbs up

Token in hand, endpoints responding — next, a tour of the code that makes all of this work.