Actions
Verify Authio's signature on every request
Authio signs every Action request with an HMAC over the timestamp and body. Your endpoint MUST verify the signature before trusting the payload.
This is non-negotiable. An attacker who can reach your endpoint over the public internet can forge auth-event payloads if you don’t verify the signature. The shape below is identical to Stripe’s — the verification snippet from your Stripe webhook adapts in a handful of lines.
The signature scheme
Authio sends three things on every action request:
Authio-Signature: t=<unix>,v1=<hex hmac>- The raw request body (UTF-8 JSON).
- The signing secret you saw once when you created (or rotated) the action — it’s the one that starts with
asec_.
The verification is:
expected = hex_lower(
hmac_sha256(
key=signing_secret,
message= timestamp + "." + body,
)
)
constant_time_equal(expected, presented_v1)Reject the request if:
- the header is missing,
- the timestamp is older than five minutes (replay window),
- the computed HMAC doesn’t match the presented
v1.
Node.js (zero-dep, standard library only)
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.AUTHIO_ACTION_SECRET; // asec_...
function verifyAuthioSignature(rawBody, signatureHeader) {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.trim().split("=")),
);
const ts = parts["t"];
const v1 = parts["v1"];
if (!ts || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) return false;
const expected = createHmac("sha256", SECRET)
.update(ts + ".")
.update(rawBody)
.digest("hex");
// timingSafeEqual requires equal-length buffers.
if (expected.length !== v1.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
const app = express();
// IMPORTANT: capture the raw body BEFORE express.json() parses it.
app.use(
"/authio-action",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
if (!verifyAuthioSignature(raw, req.header("authio-signature"))) {
return res.status(401).send({ code: "invalid_signature" });
}
const event = JSON.parse(raw);
// ... your verdict logic here ...
res.status(200).json({ decision: "allow" });
},
);Python (Flask, zero non-stdlib deps)
import hmac, hashlib, os, time
from flask import Flask, request, jsonify
SECRET = os.environ["AUTHIO_ACTION_SECRET"].encode()
app = Flask(__name__)
def verify(raw_body: bytes, header: str) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(","))
ts, v1 = parts.get("t"), parts.get("v1")
if not ts or not v1:
return False
if abs(int(time.time()) - int(ts)) > 300:
return False
expected = hmac.new(
SECRET, (ts + ".").encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/authio-action")
def authio_action():
raw = request.get_data()
if not verify(raw, request.headers.get("Authio-Signature", "")):
return jsonify(code="invalid_signature"), 401
event = request.get_json()
# ... your verdict logic here ...
return jsonify(decision="allow")
Go (net/http, zero non-stdlib deps)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("AUTHIO_ACTION_SECRET"))
func verify(raw []byte, header string) bool {
if header == "" {
return false
}
var ts, v1 string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
v1 = kv[1]
}
}
if ts == "" || v1 == "" {
return false
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if abs64(time.Now().Unix()-tsInt) > 300 {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
func main() {
http.HandleFunc("/authio-action", func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
if !verify(raw, r.Header.Get("Authio-Signature")) {
http.Error(w, `{"code":"invalid_signature"}`, http.StatusUnauthorized)
return
}
var event map[string]any
_ = json.Unmarshal(raw, &event)
// ... your verdict logic here ...
_ = json.NewEncoder(w).Encode(map[string]any{"decision": "allow"})
})
_ = http.ListenAndServe(":8080", nil)
}Ruby (Sinatra, zero non-stdlib deps)
require "sinatra"
require "openssl"
require "json"
SECRET = ENV.fetch("AUTHIO_ACTION_SECRET")
def verify(raw, header)
return false if header.nil? || header.empty?
parts = header.split(",").map { |p| p.strip.split("=", 2) }.to_h
ts = parts["t"]
v1 = parts["v1"]
return false if ts.nil? || v1.nil?
return false if (Time.now.to_i - ts.to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest("sha256", SECRET, "#{ts}.#{raw}")
Rack::Utils.secure_compare(expected, v1)
end
post "/authio-action" do
raw = request.body.read
halt 401, { code: "invalid_signature" }.to_json unless verify(raw, request.env["HTTP_AUTHIO_SIGNATURE"])
event = JSON.parse(raw)
# ... your verdict logic here ...
content_type :json
{ decision: "allow" }.to_json
endOptional: sign the response back to Authio
Authio will verify a response signature when present. To opt in, set Authio-Response-Signature: t=<unix>,v1=<hex> on your response, computed over your response body with the same secret. Authio rejects responses whose signature falls outside a ±5-minute replay window or fails the HMAC check.
// Node.js — add to your /authio-action handler
const responseBody = JSON.stringify({ decision: "allow" });
const responseTs = Math.floor(Date.now() / 1000).toString();
const responseSig = createHmac("sha256", SECRET)
.update(responseTs + ".")
.update(responseBody)
.digest("hex");
res
.status(200)
.setHeader("authio-response-signature", `t=${responseTs},v1=${responseSig}`)
.setHeader("content-type", "application/json")
.send(responseBody);