On this page
Sign-in Source Reference#
The exact contracts behind the three sign-in sources: the signed check request Gydr sends to your backend, and the key and claims a Your keys source expects.
Your backend: the check request#
Gydr sends one POST with an empty body to your check URL. The URL must be https (no IP addresses, no redirects) and answer within 3 seconds.
POST https://api.example.com/gydr/check
Gydr-Source-Id: ids_AbC123xyz
Gydr-Visitor-Token: <the visitor's token> (absent on the activation probe)
Gydr-Challenge: <random nonce> (activation probe only)
Gydr-Signature: t=1790000000,n=<nonce>,v1=<hex>[,v1=<hex>]The signature proves the call is Gydr’s. Compute it with your secret exactly like this, and compare in constant time:
v1 = hex( HMAC-SHA256( secret as UTF-8,
"gydr-check-v1\n<t>\n<sourceId>\n<n>\n<subject>" ) )
subject = lowercase hex SHA-256 of the visitor token
(for the activation probe: the Gydr-Challenge value)- Refuse a request when
|now − t|is more than 300 seconds. - Accept any of the listed
v1values: during a secret rotation Gydr sends both versions. - Answer with
Cache-Control: no-store. A200that carries anAgeheader or a public cache directive is refused.
What to answer#
- Your answer
200with{ "userId": "42", "name"?, "email"?, "expiresIn"? }- Meaning for Gydr
- Signed in. JSON of at most 4 KB with no other members;
userIdis a string of 1 to 255 printable characters.
- Your answer
401- Meaning for Gydr
- Not signed in. Cached for 60 seconds; the visitor is a guest.
- Your answer
403- Meaning for Gydr
- “I refuse your signature”. Reported as
bad_signaturewith an operator alert — and never a sign-out.
- Your answer
- Timeout, 5xx
- Meaning for Gydr
check_unavailable. Gydr keeps the visitor signed in on the same token for a short grace period.
- Your answer
- Anything else
- Meaning for Gydr
misconfigured.
| Your answer | Meaning for Gydr |
|---|---|
200 with { "userId": "42", "name"?, "email"?, "expiresIn"? } | Signed in. JSON of at most 4 KB with no other members; userId is a string of 1 to 255 printable characters. |
401 | Not signed in. Cached for 60 seconds; the visitor is a guest. |
403 | “I refuse your signature”. Reported as bad_signature with an operator alert — and never a sign-out. |
| Timeout, 5xx | check_unavailable. Gydr keeps the visitor signed in on the same token for a short grace period. |
| Anything else | misconfigured. |
Verify and answer#
const crypto = require('node:crypto')
const SECRET = process.env.GYDR_CHECK_SECRET
const hmacHex = (key, text) =>
crypto.createHmac('sha256', key).update(text).digest('hex')
const sha256Hex = (text) =>
crypto.createHash('sha256').update(text).digest('hex')
function parseSignature(header) {
const sig = { t: 0, n: '', v1: [] }
for (const part of String(header || '').split(',')) {
const [k, v] = part.split('=')
if (k === 't') sig.t = Number(v)
else if (k === 'n') sig.n = v
else if (k === 'v1') sig.v1.push(v)
}
return sig.t && sig.n && sig.v1.length ? sig : null
}
function verifyGydrSignature(req, res, next) {
const sig = parseSignature(req.get('Gydr-Signature'))
if (!sig || Math.abs(Date.now() / 1000 - sig.t) > 300) return res.sendStatus(403)
const subject =
req.get('Gydr-Challenge') ?? sha256Hex(req.get('Gydr-Visitor-Token') ?? '')
const base = ['gydr-check-v1', sig.t, req.get('Gydr-Source-Id'), sig.n, subject].join('\n')
const expected = Buffer.from(hmacHex(SECRET, base), 'hex')
const ok = sig.v1.some((v) => {
const got = Buffer.from(v, 'hex')
return got.length === expected.length && crypto.timingSafeEqual(got, expected)
})
return ok ? next() : res.sendStatus(403)
}
app.post('/gydr/check', verifyGydrSignature, async (req, res) => {
res.set('Cache-Control', 'no-store')
const challenge = req.get('Gydr-Challenge') // activation probe
if (challenge) return res.json({ challenge: hmacHex(SECRET, challenge) })
const user = await userForApiToken(req.get('Gydr-Visitor-Token'))
if (!user) return res.sendStatus(401)
res.json({ userId: String(user.id), name: user.name, email: user.email })
})Activate, rotate, retire#
- Activate. The Console sends a probe carrying
Gydr-Challenge; your endpoint answers{ "challenge": hex(HMAC(secret, challenge)) }. The snippets above already do. A source works only after a probe passes, and any edit of the check URL deactivates it until a new probe passes. - Rotate. Rotating issues a new secret (shown once) and Gydr signs every request with both until you press Retire the old secret, so rotation signs nobody out.
- Egress. Gydr calls from fixed addresses, listed in Console → Settings → Visitor sign-in, if you allow-list.
Your keys: claims and the key-pair command#
A Your keys source accepts RS256, ES256 or EdDSA JWTs. The token must carry:
- Claim
iss- Required
- Yes
- Notes
- Must equal the issuer you registered.
- Claim
aud/azp/client_id- Required
- Required for a JWKS URL; optional for a pasted key
- Notes
- One of them must equal the audience you registered.
- Claim
sub- Required
- Yes
- Notes
- The User ID, unless you name another claim. 1 to 255 printable characters; it must never change for the same person.
- Claim
exp- Required
- Yes
- Notes
- At most 24 hours after the token was issued; about 1 hour is typical.
- Claim
name,email,phone_number- Required
- No
- Notes
- Used to fill the visitor's details where empty. Phone is read from phone_number.
| Claim | Required | Notes |
|---|---|---|
iss | Yes | Must equal the issuer you registered. |
aud / azp / client_id | Required for a JWKS URL; optional for a pasted key | One of them must equal the audience you registered. |
sub | Yes | The User ID, unless you name another claim. 1 to 255 printable characters; it must never change for the same person. |
exp | Yes | At most 24 hours after the token was issued; about 1 hour is typical. |
name, email, phone_number | No | Used to fill the visitor's details where empty. Phone is read from phone_number. |
Limits Gydr enforces
crit, jku, jwk, x5u or x5c are refused, and up to 60 seconds of clock difference is allowed. The issuer, JWKS URL and any URL you register must be https.Sign your own token#
For a site with cookie sessions and no identity provider. Create a key pair once, paste the public key in the Console, and keep the private key on your server:
openssl ecparam -name prime256v1 -genkey -noout -out gydr-ec.pem
openssl pkcs8 -topk8 -nocrypt -in gydr-ec.pem -out gydr-private.pem # keep on your server
openssl ec -in gydr-ec.pem -pubout -out gydr-public.pem # paste in the Consoleimport { SignJWT, importPKCS8 } from 'jose'
const key = await importPKCS8(process.env.GYDR_VISITOR_KEY, 'ES256')
app.get('/gydr/visitor-token', async (req, res) => {
res.set('Cache-Control', 'no-store')
const u = req.session.user
if (!u) return res.json({ visitorToken: null })
const visitorToken = await new SignJWT({ name: u.name, email: u.email })
.setProtectedHeader({ alg: 'ES256' })
.setSubject(String(u.id))
.setIssuer('https://www.example.com')
.setIssuedAt()
.setExpirationTime('1h')
.sign(key)
res.json({ visitorToken })
})Register the issuer (https://www.example.com above) and the public key; the audience is optional for a pasted key. This is the “recognise people only” setup: no plugin receives the token.
