Skip to content

Authentication

StoreFront integrates with your authentication system to ensure only your intended audience can access your gift card store. This allows you to lock down access to employees, members, or customers based on your existing identity infrastructure.

Use Mature Libraries

If you're implementing authentication yourself, we strongly recommend using well-established, battle-tested libraries for your chosen protocol. Mature libraries handle edge cases, security considerations, and protocol nuances that are easy to overlook in custom implementations - and can significantly reduce your integration effort.

StoreFront supports the following authentication methods:

MethodBest ForComplexity
Third-Party IdPBuyers using Auth0, Okta, Azure AD, etc.Low
OAuth 2.0Custom OAuth implementationsMedium
OpenID Connect (OIDC)Custom OIDC implementationsMedium
JWTMinimal integration, direct token exchangeLow to Medium

Using a Third-Party Identity Provider

Identity-as-a-Service providers like Auth0, Okta, Azure AD, and Google Identity handle authentication for you - security, MFA, user management, and scaling are all managed. If you already use one, connecting to StoreFront is straightforward. If you're building from scratch, they're worth considering for these same reasons.

Setup

Setup takes just a few minutes on each side:

In your IdP:

  1. Create a new application for StoreFront
  2. Paste in the callback URL we provide (e.g., https://your.storefront/callback)
  3. Copy your Client ID, Client Secret, and Discovery URL

Send to Tillo:

SettingExample
Client IDabc123def456
Client Secretsecret_xyz789
Discovery URLhttps://your-tenant.auth0.com/.well-known/openid-configuration

That's it - we handle the rest. Users are redirected to your IdP's login page, authenticate there, and are returned to StoreFront with a valid session.

JWT Authentication

JWT (JSON Web Token) authentication is a simple, direct method where of authentication where your system generates a signed token containing user information. This is ideal when you want minimal integration complexity or are building a custom authentication flow.

How It Works

null

Key Verification Methods

StoreFront supports three methods for verifying JWT signatures, each with different security and operational characteristics:

Your system hosts a JWKS (JSON Web Key Set) endpoint containing your public keys. StoreFront fetches keys automatically and handles rotation seamlessly.

Security: Highest
Key Rotation: Self-service, no coordination required

Your Requirements:

  • Generate an RSA or EC key pair
  • Host a JWKS endpoint (e.g., https://auth.your-company.com/.well-known/jwks.json)
  • Sign JWTs with your private key
  • Include the kid (key ID) in JWT headers

JWKS Endpoint Format:

json
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-2024-01",
      "use": "sig",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuu...",
      "e": "AQAB"
    }
  ]
}
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-2024-01",
      "use": "sig",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuu...",
      "e": "AQAB"
    }
  ]
}

Key Rotation: Add a new key to your JWKS, start signing with the new key, then remove the old key after a transition period.

Generating Key Pairs

Use OpenSSL to generate an RSA key pair for use with RS256:

bash
# Generate a 2048-bit RSA private key
openssl genrsa -out private-key.pem 2048

# Extract the public key
openssl rsa -in private-key.pem -pubout -out public-key.pem
# Generate a 2048-bit RSA private key
openssl genrsa -out private-key.pem 2048

# Extract the public key
openssl rsa -in private-key.pem -pubout -out public-key.pem

Algorithm Options

RS256 with a 2048-bit key provides strong security for most use cases. If your security requirements demand it, RS384 and RS512 are also supported, as are elliptic curve algorithms (ES256, ES384). For RS512, consider using a 4096-bit key for optimal security matching.

Protect Your Private Keys

  • Never commit private keys to version control
  • Use secrets management (AWS Secrets Manager, HashiCorp Vault, etc.)
Building a JWKS Endpoint
null

Your JWKS endpoint must return a JSON document containing your public keys in JWK (JSON Web Key) format.

Code Examples - For Guidance Only

The following code examples are provided for illustrative purposes only and are not intended for production use. You should:

  • Use well-established, actively maintained libraries for JWK generation and JWKS hosting
  • Refer to RFC 7517 (JSON Web Key) for the authoritative specification
  • Conduct thorough security review and testing before deploying to production
  • Consider using existing JWKS solutions from your identity provider or framework
javascript
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');

const app = express();

// Load your public key(s)
const publicKeyPem = fs.readFileSync('public-key.pem', 'utf8');

// Convert PEM to JWK format
function pemToJwk(pem, kid, alg) {
  const key = crypto.createPublicKey(pem);
  const jwk = key.export({ format: 'jwk' });
  
  return {
    ...jwk,
    kid: kid,
    use: 'sig',
    alg: alg
  };
}

// JWKS endpoint
app.get('/.well-known/jwks.json', (req, res) => {
  const jwks = {
    keys: [
      pemToJwk(publicKeyPem, 'key-2024-01', 'RS256'),
      // Add more keys here for rotation
    ]
  };
  
  res.json(jwks);
});

app.listen(3000);
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');

const app = express();

// Load your public key(s)
const publicKeyPem = fs.readFileSync('public-key.pem', 'utf8');

// Convert PEM to JWK format
function pemToJwk(pem, kid, alg) {
  const key = crypto.createPublicKey(pem);
  const jwk = key.export({ format: 'jwk' });
  
  return {
    ...jwk,
    kid: kid,
    use: 'sig',
    alg: alg
  };
}

// JWKS endpoint
app.get('/.well-known/jwks.json', (req, res) => {
  const jwks = {
    keys: [
      pemToJwk(publicKeyPem, 'key-2024-01', 'RS256'),
      // Add more keys here for rotation
    ]
  };
  
  res.json(jwks);
});

app.listen(3000);
python
from flask import Flask, jsonify
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
import json

app = Flask(__name__)

def pem_to_jwk(pem_path, kid, alg):
    """Convert a PEM public key to JWK format."""
    with open(pem_path, 'rb') as f:
        public_key = serialization.load_pem_public_key(f.read(), backend=default_backend())
    
    # Get the public numbers
    numbers = public_key.public_numbers()
    
    # RSA key
    if hasattr(numbers, 'n'):
        def int_to_base64url(n):
            byte_length = (n.bit_length() + 7) // 8
            return base64.urlsafe_b64encode(
                n.to_bytes(byte_length, 'big')
            ).rstrip(b'=').decode('ascii')
        
        return {
            'kty': 'RSA',
            'kid': kid,
            'use': 'sig',
            'alg': alg,
            'n': int_to_base64url(numbers.n),
            'e': int_to_base64url(numbers.e)
        }
    
    # EC key
    else:
        curve_name = public_key.curve.name
        crv = {'secp256r1': 'P-256', 'secp384r1': 'P-384'}[curve_name]
        coord_size = {'P-256': 32, 'P-384': 48}[crv]
        
        def int_to_base64url(n, size):
            return base64.urlsafe_b64encode(
                n.to_bytes(size, 'big')
            ).rstrip(b'=').decode('ascii')
        
        return {
            'kty': 'EC',
            'kid': kid,
            'use': 'sig',
            'alg': alg,
            'crv': crv,
            'x': int_to_base64url(numbers.x, coord_size),
            'y': int_to_base64url(numbers.y, coord_size)
        }

@app.route('/.well-known/jwks.json')
def jwks():
    keys = [
        pem_to_jwk('public-key.pem', 'key-2024-01', 'RS256'),
        # Add more keys here for rotation
    ]
    return jsonify({'keys': keys})

if __name__ == '__main__':
    app.run(port=3000)
from flask import Flask, jsonify
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
import json

app = Flask(__name__)

def pem_to_jwk(pem_path, kid, alg):
    """Convert a PEM public key to JWK format."""
    with open(pem_path, 'rb') as f:
        public_key = serialization.load_pem_public_key(f.read(), backend=default_backend())
    
    # Get the public numbers
    numbers = public_key.public_numbers()
    
    # RSA key
    if hasattr(numbers, 'n'):
        def int_to_base64url(n):
            byte_length = (n.bit_length() + 7) // 8
            return base64.urlsafe_b64encode(
                n.to_bytes(byte_length, 'big')
            ).rstrip(b'=').decode('ascii')
        
        return {
            'kty': 'RSA',
            'kid': kid,
            'use': 'sig',
            'alg': alg,
            'n': int_to_base64url(numbers.n),
            'e': int_to_base64url(numbers.e)
        }
    
    # EC key
    else:
        curve_name = public_key.curve.name
        crv = {'secp256r1': 'P-256', 'secp384r1': 'P-384'}[curve_name]
        coord_size = {'P-256': 32, 'P-384': 48}[crv]
        
        def int_to_base64url(n, size):
            return base64.urlsafe_b64encode(
                n.to_bytes(size, 'big')
            ).rstrip(b'=').decode('ascii')
        
        return {
            'kty': 'EC',
            'kid': kid,
            'use': 'sig',
            'alg': alg,
            'crv': crv,
            'x': int_to_base64url(numbers.x, coord_size),
            'y': int_to_base64url(numbers.y, coord_size)
        }

@app.route('/.well-known/jwks.json')
def jwks():
    keys = [
        pem_to_jwk('public-key.pem', 'key-2024-01', 'RS256'),
        # Add more keys here for rotation
    ]
    return jsonify({'keys': keys})

if __name__ == '__main__':
    app.run(port=3000)
php
<?php
// Using web-token/jwt-key-mgmt for JWK conversion
// composer require web-token/jwt-key-mgmt

use Jose\Component\Core\JWK;
use Jose\Component\KeyManagement\JWKFactory;

// Convert PEM to JWK
function pemToJwk(string $pemPath, string $kid, string $alg): array {
    $pem = file_get_contents($pemPath);
    $jwk = JWKFactory::createFromKey($pem);
    
    return array_merge($jwk->all(), [
        'kid' => $kid,
        'use' => 'sig',
        'alg' => $alg
    ]);
}

// Simple JWKS endpoint (e.g., in a Laravel route or standalone script)
header('Content-Type: application/json');

$jwks = [
    'keys' => [
        pemToJwk('public-key.pem', 'key-2024-01', 'RS256'),
        // Add more keys here for rotation
    ]
];

echo json_encode($jwks);
<?php
// Using web-token/jwt-key-mgmt for JWK conversion
// composer require web-token/jwt-key-mgmt

use Jose\Component\Core\JWK;
use Jose\Component\KeyManagement\JWKFactory;

// Convert PEM to JWK
function pemToJwk(string $pemPath, string $kid, string $alg): array {
    $pem = file_get_contents($pemPath);
    $jwk = JWKFactory::createFromKey($pem);
    
    return array_merge($jwk->all(), [
        'kid' => $kid,
        'use' => 'sig',
        'alg' => $alg
    ]);
}

// Simple JWKS endpoint (e.g., in a Laravel route or standalone script)
header('Content-Type: application/json');

$jwks = [
    'keys' => [
        pemToJwk('public-key.pem', 'key-2024-01', 'RS256'),
        // Add more keys here for rotation
    ]
];

echo json_encode($jwks);

JWKS Response Example (RSA)

json
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-2024-01",
      "use": "sig",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt...",
      "e": "AQAB"
    }
  ]
}
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-2024-01",
      "use": "sig",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt...",
      "e": "AQAB"
    }
  ]
}

In our JWT flow, the entire authentication journey looks like the following with the JWKS flow added:

null

EC Keys

If using elliptic curve algorithms (ES256, ES384), the JWK format differs - see RFC 7518 Section 6.2 for the EC key format specification.

Caching Considerations

StoreFront caches JWKS responses for performance. When rotating keys:

  1. Add the new key to your JWKS
  2. Start signing with the new key
  3. StoreFront will retry validation with a fresh JWKS fetch automatically if a key cannot be resolved or token validation fails
  4. Remove the old key after a grace period

StoreFront caches JWKS responses for up to 1 hour, but key rotation does not require waiting for cache expiry.

2. Asymmetric Keys with Upload

You generate a key pair and upload the public key to StoreFront. This is suitable when you cannot host a JWKS endpoint.

Security: High
Key Rotation: Requires coordination with Tillo

Your Requirements:

  • Generate an RSA or EC key pair
  • Provide the public key to Tillo during setup
  • Sign JWTs with your private key
  • Include the kid (key ID) provided by Tillo in JWT headers

Key Rotation: Contact Tillo to upload a new public key. Both keys can be active during the transition period.

3. Symmetric Keys (HMAC Shared Secret)

Security: Moderate
Key Rotation: Requires coordination with Tillo

A shared secret is generated by StoreFront and provided to you. Both parties use the same secret for signing and verification.

Security Consideration

Symmetric keys have a wider attack surface because the same secret is used for both signing and verification. If your system is compromised, an attacker could forge tokens. Use asymmetric keys when possible.

Your Requirements:

  • Receive the shared secret from Tillo during setup
  • Sign JWTs using HS256
  • Include the kid (key ID) provided by Tillo in JWT headers
  • Store the secret securely - never expose it in client-side code

Algorithm Options

HS256 provides strong security for most use cases. HS384 and HS512 are also available if your security requirements demand stronger hashing.

Key Rotation: Contact Tillo to generate a new secret. A grace period allows both secrets to be valid during transition.

JWT Structure

A JWT consists of three parts: header, payload, and signature.

json
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2024-01"
}
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2024-01"
}
FieldDescription
algSigning algorithm (e.g., RS256 for asymmetric, HS256 for symmetric)
typToken type, always JWT
kidKey ID for key lookup (required)

Payload (Claims)

json
{
  "iss": "urn:tillo:storefront:production:buyer:acme-corp",
  "aud": "urn:tillo:storefront:production:buyer:acme-corp",
  "sub": "user-12345",
  "exp": 1735689600,
  "iat": 1735689300,
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "name": "John Doe",
  "email": "user@example.com"
}
{
  "iss": "urn:tillo:storefront:production:buyer:acme-corp",
  "aud": "urn:tillo:storefront:production:buyer:acme-corp",
  "sub": "user-12345",
  "exp": 1735689600,
  "iat": 1735689300,
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "name": "John Doe",
  "email": "user@example.com"
}
ClaimRequiredDescriptionProvided By
issYesIssuer identifier - must match the value agreed during setupAgreed during setup
audYesAudience identifier - must match the value agreed during setupAgreed during setup
subYesSubject - unique user identifierYou (Buyer)
expYesExpiration time (Unix timestamp)You (Buyer)
iatYesIssued at time (Unix timestamp)You (Buyer)
jtiConfigurableJWT ID for replay protection (UUID v4 or base64url, 22-128 chars)You (Buyer)
nameOptionalUser's display name - used to pre-populate checkoutYou (Buyer)
emailOptionalUser's email address - used to pre-populate checkoutYou (Buyer)

Issuer and Audience Format:

Both iss and aud use the same URN format to prevent cross-system/environment token reuse:

urn:tillo:storefront:{environment}:buyer:{you-acme-ltd}
urn:tillo:storefront:{environment}:buyer:{you-acme-ltd}

For example: urn:tillo:storefront:production:buyer:acme-corp

The exact values will be provided during setup and must match exactly in your JWT.

Token Delivery Methods

StoreFront strictly enforces the configured delivery method. Tokens sent via the wrong method will be rejected.

POST Delivery (Recommended)

The JWT is sent in the POST body to StoreFront's callback endpoint:

http
POST /callback HTTP/1.1
Content-Type: application/x-www-form-urlencoded

id_token=eyJhbGciOiJSUzI1NiIs...
POST /callback HTTP/1.1
Content-Type: application/x-www-form-urlencoded

id_token=eyJhbGciOiJSUzI1NiIs...
RequirementValue
MethodPOST
Content-Typeapplication/x-www-form-urlencoded
Field nameid_token
Endpoint/callback

This is more secure as tokens are not exposed in URLs, browser history, or server logs.

Optional Claims with POST

When using POST delivery, you can safely include name and email claims in your JWT to pre-populate the checkout page for a smoother user experience. These fields are optional but provide convenience for users.

URL Delivery (Discouraged)

The JWT is appended as a query parameter:

https://your.storefront/callback?token=eyJhbGciOiJSUzI1NiIs...
https://your.storefront/callback?token=eyJhbGciOiJSUzI1NiIs...

Critical Security Warning - URL Delivery Risks

URL delivery exposes your entire JWT to multiple attack vectors:

  • Browser history - Token persists indefinitely on user's device
  • Server access logs - Token recorded in your servers, proxies, CDNs, and WAFs
  • Referer headers - Token leaked to any third-party resources on the page
  • Shoulder surfing - Token visible in address bar
  • Shared computers - Token accessible to subsequent users

If you must use URL delivery:

  • NEVER include name, email, or any personally identifiable information
  • Keep token expiry as short as possible
  • StoreFront enforces replay protection by validating the jti claim
  • Consider the regulatory implications (GDPR, CCPA) of token exposure

For new integrations, always use POST delivery.

Security Features

Replay Protection (JTI)

When enabled, each JWT can only be used once. StoreFront tracks the jti claim and rejects any token that has already been used, preventing replay attacks where an attacker intercepts and reuses a valid token.

How it works:

  1. You generate a unique jti for each token
  2. StoreFront records the jti when the token is first used
  3. Any subsequent use of the same jti is rejected
  4. The jti is tracked until the token's exp time passes

JTI Requirements:

  • Must be cryptographically random and unpredictable
  • Accepted formats:
    • UUID v4 with hyphens: 550e8400-e29b-41d4-a716-446655440000
    • UUID v4 without hyphens: 550e8400e29b41d4a716446655440000
    • Base64url string: 22-128 characters using [A-Za-z0-9_-]
  • Must have high entropy - do not use sequential numbers, timestamps, or predictable patterns

Token Freshness

StoreFront validates that tokens are fresh and not stale:

  • exp: Token must not be expired (with configurable clock skew tolerance, default: 30 seconds)
  • iat: Token must not exceed the maximum age from issuance (default: 60 seconds)

Why 60 seconds?

JWTs for StoreFront are login assertions, not access tokens. They should be generated just-in-time and used immediately. The short maximum age prevents tokens from being intercepted and used later.

Issuer and Audience Validation

  • iss and aud: Must match the values provided during setup

Example Implementation

Code Examples - For Guidance Only

The following code examples are provided for illustrative purposes only and are not intended for production use without modification. You should:

  • Use well-established JWT libraries appropriate for your language/framework
  • Refer to RFC 7519 (JWT) and RFC 7515 (JWS) for authoritative specifications
  • Conduct thorough security review and testing before deploying to production
  • Ensure your implementation handles errors, edge cases, and key management securely

Here's an example of generating a JWT and sending it to StoreFront via POST:

javascript
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');

const privateKey = fs.readFileSync('private-key.pem');

// Values provided during setup
const ISS_AUD = 'urn:tillo:storefront:production:buyer:acme-corp';
const STOREFRONT_CALLBACK = 'https://giftcards.acme-corp.com/callback';

function generateToken(userId, userName, userEmail) {
  const now = Math.floor(Date.now() / 1000);
  
  return jwt.sign(
    {
      iss: ISS_AUD,
      aud: ISS_AUD,
      sub: userId,
      name: userName,      // Optional - pre-populates checkout
      email: userEmail,    // Optional - pre-populates checkout
      jti: uuidv4(),       // Unique token identifier for replay protection
      iat: now,
      exp: now + 60        // 60 seconds - generate just-in-time
    },
    privateKey,
    { 
      algorithm: 'RS256',
      header: { kid: 'key-2024-01' }
    }
  );
}

// Send via POST (recommended)
// Return an HTML form that auto-submits to StoreFront
function redirectToStoreFront(token) {
  return `
    <html>
      <body onload="document.forms[0].submit()">
        <form method="POST" action="${STOREFRONT_CALLBACK}">
          <input type="hidden" name="id_token" value="${token}" />
        </form>
      </body>
    </html>
  `;
}
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');

const privateKey = fs.readFileSync('private-key.pem');

// Values provided during setup
const ISS_AUD = 'urn:tillo:storefront:production:buyer:acme-corp';
const STOREFRONT_CALLBACK = 'https://giftcards.acme-corp.com/callback';

function generateToken(userId, userName, userEmail) {
  const now = Math.floor(Date.now() / 1000);
  
  return jwt.sign(
    {
      iss: ISS_AUD,
      aud: ISS_AUD,
      sub: userId,
      name: userName,      // Optional - pre-populates checkout
      email: userEmail,    // Optional - pre-populates checkout
      jti: uuidv4(),       // Unique token identifier for replay protection
      iat: now,
      exp: now + 60        // 60 seconds - generate just-in-time
    },
    privateKey,
    { 
      algorithm: 'RS256',
      header: { kid: 'key-2024-01' }
    }
  );
}

// Send via POST (recommended)
// Return an HTML form that auto-submits to StoreFront
function redirectToStoreFront(token) {
  return `
    <html>
      <body onload="document.forms[0].submit()">
        <form method="POST" action="${STOREFRONT_CALLBACK}">
          <input type="hidden" name="id_token" value="${token}" />
        </form>
      </body>
    </html>
  `;
}
python
import jwt
import uuid
import time

private_key = open('private-key.pem').read()

# Values provided during setup
ISS_AUD = 'urn:tillo:storefront:production:buyer:acme-corp'
STOREFRONT_CALLBACK = 'https://giftcards.acme-corp.com/callback'

def generate_token(user_id, user_name=None, user_email=None):
    now = int(time.time())
    
    payload = {
        'iss': ISS_AUD,
        'aud': ISS_AUD,
        'sub': user_id,
        'jti': str(uuid.uuid4()),  # Unique token identifier for replay protection
        'iat': now,
        'exp': now + 60            # 60 seconds - generate just-in-time
    }
    
    # Optional claims - only include with POST delivery
    if user_name:
        payload['name'] = user_name
    if user_email:
        payload['email'] = user_email
    
    return jwt.encode(
        payload,
        private_key,
        algorithm='RS256',
        headers={'kid': 'key-2024-01'}
    )

# Send via POST using an HTML form that auto-submits
def redirect_html(token):
    return f'''
    <html>
      <body onload="document.forms[0].submit()">
        <form method="POST" action="{STOREFRONT_CALLBACK}">
          <input type="hidden" name="id_token" value="{token}" />
        </form>
      </body>
    </html>
    '''
import jwt
import uuid
import time

private_key = open('private-key.pem').read()

# Values provided during setup
ISS_AUD = 'urn:tillo:storefront:production:buyer:acme-corp'
STOREFRONT_CALLBACK = 'https://giftcards.acme-corp.com/callback'

def generate_token(user_id, user_name=None, user_email=None):
    now = int(time.time())
    
    payload = {
        'iss': ISS_AUD,
        'aud': ISS_AUD,
        'sub': user_id,
        'jti': str(uuid.uuid4()),  # Unique token identifier for replay protection
        'iat': now,
        'exp': now + 60            # 60 seconds - generate just-in-time
    }
    
    # Optional claims - only include with POST delivery
    if user_name:
        payload['name'] = user_name
    if user_email:
        payload['email'] = user_email
    
    return jwt.encode(
        payload,
        private_key,
        algorithm='RS256',
        headers={'kid': 'key-2024-01'}
    )

# Send via POST using an HTML form that auto-submits
def redirect_html(token):
    return f'''
    <html>
      <body onload="document.forms[0].submit()">
        <form method="POST" action="{STOREFRONT_CALLBACK}">
          <input type="hidden" name="id_token" value="{token}" />
        </form>
      </body>
    </html>
    '''
php
use Firebase\JWT\JWT;
use Ramsey\Uuid\Uuid;

$privateKey = file_get_contents('private-key.pem');

// Values provided during setup
const ISS_AUD = 'urn:tillo:storefront:production:buyer:acme-corp';
const STOREFRONT_CALLBACK = 'https://giftcards.acme-corp.com/callback';

function generateToken(string $userId, ?string $userName = null, ?string $userEmail = null): string {
    global $privateKey;
    
    $now = time();
    
    $payload = [
        'iss' => ISS_AUD,
        'aud' => ISS_AUD,
        'sub' => $userId,
        'jti' => Uuid::uuid4()->toString(),  // Unique token identifier for replay protection
        'iat' => $now,
        'exp' => $now + 60                   // 60 seconds - generate just-in-time
    ];
    
    // Optional claims - only include with POST delivery
    if ($userName) {
        $payload['name'] = $userName;
    }
    if ($userEmail) {
        $payload['email'] = $userEmail;
    }
    
    return JWT::encode($payload, $privateKey, 'RS256', 'key-2024-01');
}

// Send via POST using an HTML form that auto-submits
function redirectHtml(string $token): string {
    return <<<HTML
    <html>
      <body onload="document.forms[0].submit()">
        <form method="POST" action="STOREFRONT_CALLBACK">
          <input type="hidden" name="id_token" value="{$token}" />
        </form>
      </body>
    </html>
    HTML;
}
use Firebase\JWT\JWT;
use Ramsey\Uuid\Uuid;

$privateKey = file_get_contents('private-key.pem');

// Values provided during setup
const ISS_AUD = 'urn:tillo:storefront:production:buyer:acme-corp';
const STOREFRONT_CALLBACK = 'https://giftcards.acme-corp.com/callback';

function generateToken(string $userId, ?string $userName = null, ?string $userEmail = null): string {
    global $privateKey;
    
    $now = time();
    
    $payload = [
        'iss' => ISS_AUD,
        'aud' => ISS_AUD,
        'sub' => $userId,
        'jti' => Uuid::uuid4()->toString(),  // Unique token identifier for replay protection
        'iat' => $now,
        'exp' => $now + 60                   // 60 seconds - generate just-in-time
    ];
    
    // Optional claims - only include with POST delivery
    if ($userName) {
        $payload['name'] = $userName;
    }
    if ($userEmail) {
        $payload['email'] = $userEmail;
    }
    
    return JWT::encode($payload, $privateKey, 'RS256', 'key-2024-01');
}

// Send via POST using an HTML form that auto-submits
function redirectHtml(string $token): string {
    return <<<HTML
    <html>
      <body onload="document.forms[0].submit()">
        <form method="POST" action="STOREFRONT_CALLBACK">
          <input type="hidden" name="id_token" value="{$token}" />
        </form>
      </body>
    </html>
    HTML;
}

OAuth 2.0

OAuth 2.0 is an industry-standard authorisation framework that enables secure, delegated access without sharing credentials.

Mature Server Libraries Available

If you're building your own OAuth 2.0 authorization server, consider using established server-side libraries:

These handle the protocol complexities and security considerations for you.

These are suggestions based on what was available at the time of writing. You should conduct your own research to find a package that you are comfortable using, trust, and that is actively maintained and recommended by the industry or community.

How It Works

StoreFront uses the Authorization Code Grant flow, which is the most secure OAuth flow for server-side applications:

null

Scopes

OAuth 2.0 doesn't prescribe specific scopes - they're defined by your system. During setup, you'll tell us which scopes to request (e.g., email profile). Custom scopes are supported.

Your Implementation Requirements

You need to provide the following endpoints:

Authorization Endpoint

GET /authorize

StoreFront redirects unauthenticated users here with:

ParameterDescription
response_typeAlways code
client_idYour assigned client ID
redirect_uriStoreFront's callback URL
stateCSRF protection token (must be returned unchanged)
scopeRequested scopes - configured during setup (e.g., email profile)

After authenticating the user, redirect back to StoreFront's callback with:

{redirect_uri}?code={authorization_code}&state={state}
{redirect_uri}?code={authorization_code}&state={state}

Token Endpoint

POST /token

StoreFront exchanges the authorization code for an access token:

json
{
  "grant_type": "authorization_code",
  "code": "{authorization_code}",
  "redirect_uri": "{redirect_uri}",
  "client_id": "{client_id}",
  "client_secret": "{client_secret}"
}
{
  "grant_type": "authorization_code",
  "code": "{authorization_code}",
  "redirect_uri": "{redirect_uri}",
  "client_id": "{client_id}",
  "client_secret": "{client_secret}"
}

Return:

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

User Info Endpoint (Optional)

GET /userinfo

If configured, StoreFront calls this with the access token to retrieve user details:

Authorization: Bearer {access_token}
Authorization: Bearer {access_token}

Return user information including a unique identifier:

json
{
  "sub": "user-unique-id",
  "email": "user@example.com",
  "name": "John Doe"
}
{
  "sub": "user-unique-id",
  "email": "user@example.com",
  "name": "John Doe"
}

Security Considerations

  • The authorization code is single-use and short-lived (typically 10 minutes)
  • The state parameter prevents CSRF attacks - always validate it matches
  • Token exchange happens server-to-server, never exposing secrets to the browser

PKCE Support (Optional)

StoreFront supports PKCE (Proof Key for Code Exchange) for enhanced security. PKCE protects against authorization code interception attacks by binding the authorization request to the token exchange.

Supported Methods:

MethodDescriptionRecommendation
S256SHA-256 hash of the code verifierRecommended
plainCode verifier sent as-isNot recommended for production

How It Works:

  1. StoreFront generates a random code_verifier (43-128 characters)
  2. StoreFront creates code_challenge = BASE64URL(SHA256(code_verifier)) (for S256)
  3. The code_challenge and code_challenge_method are sent with the authorization request
  4. Your token endpoint receives the code_verifier during code exchange
  5. Your server validates: BASE64URL(SHA256(code_verifier)) == stored_code_challenge

Authorization Request (with PKCE):

GET /authorize?
  response_type=code&
  client_id=storefront&
  redirect_uri=https://your.storefront/callback&
  state=xyz123&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256
GET /authorize?
  response_type=code&
  client_id=storefront&
  redirect_uri=https://your.storefront/callback&
  state=xyz123&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256

Token Exchange Validation:

Your token endpoint must validate the code_verifier matches the original code_challenge:

Code Examples - For Guidance Only

The following code examples are provided for illustrative purposes only. For production implementations, use your OAuth library's built-in PKCE validation or refer to RFC 7636 (PKCE) for the authoritative specification.

javascript
const crypto = require('crypto');

function validatePkce(codeVerifier, storedCodeChallenge, method) {
  if (method === 'plain') {
    return codeVerifier === storedCodeChallenge;
  }
  
  // S256
  const hash = crypto.createHash('sha256').update(codeVerifier).digest();
  const computed = hash.toString('base64url');
  
  return computed === storedCodeChallenge;
}
const crypto = require('crypto');

function validatePkce(codeVerifier, storedCodeChallenge, method) {
  if (method === 'plain') {
    return codeVerifier === storedCodeChallenge;
  }
  
  // S256
  const hash = crypto.createHash('sha256').update(codeVerifier).digest();
  const computed = hash.toString('base64url');
  
  return computed === storedCodeChallenge;
}
python
import hashlib
import base64

def validate_pkce(code_verifier: str, stored_code_challenge: str, method: str) -> bool:
    if method == 'plain':
        return code_verifier == stored_code_challenge
    
    # S256
    digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
    computed = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
    
    return computed == stored_code_challenge
import hashlib
import base64

def validate_pkce(code_verifier: str, stored_code_challenge: str, method: str) -> bool:
    if method == 'plain':
        return code_verifier == stored_code_challenge
    
    # S256
    digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
    computed = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
    
    return computed == stored_code_challenge
php
function validatePkce(string $codeVerifier, string $storedCodeChallenge, string $method): bool {
    if ($method === 'plain') {
        return hash_equals($codeVerifier, $storedCodeChallenge);
    }
    
    // S256
    $hash = hash('sha256', $codeVerifier, true);
    $computed = rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
    
    return hash_equals($computed, $storedCodeChallenge);
}
function validatePkce(string $codeVerifier, string $storedCodeChallenge, string $method): bool {
    if ($method === 'plain') {
        return hash_equals($codeVerifier, $storedCodeChallenge);
    }
    
    // S256
    $hash = hash('sha256', $codeVerifier, true);
    $computed = rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
    
    return hash_equals($computed, $storedCodeChallenge);
}

PKCE is recommended for all new OAuth integrations.

OpenID Connect (OIDC)

OpenID Connect is an identity layer built on top of OAuth 2.0. It adds standardised identity claims and discovery mechanisms.

Mature Server Libraries Available

If you're building your own OIDC provider, use server-side libraries that handle the protocol complexities:

These libraries implement the full OIDC specification including discovery, ID tokens, and JWKS endpoints.

These are suggestions based on what was available at the time of writing. You should conduct your own research to find a package that you are comfortable using, trust, and that is actively maintained and recommended by the industry or community.

Differences from OAuth 2.0

FeatureOAuth 2.0OIDC
PurposeAuthorizationAuthentication + Authorization
ID TokenNoYes (JWT with user claims)
Standard ClaimsNoYes (sub, email, name, etc.)
DiscoveryNoYes (/.well-known/openid-configuration)

Scopes

StoreFront sends the openid scope (required for OIDC) along with standard scopes like profile and email. Custom scopes can also be configured during setup.

Your Implementation Requirements

OIDC requires the same endpoints as OAuth 2.0, plus:

GET /.well-known/openid-configuration

Returns your OIDC configuration:

json
{
  "issuer": "https://auth.your-company.com",
  "authorization_endpoint": "https://auth.your-company.com/authorize",
  "token_endpoint": "https://auth.your-company.com/token",
  "userinfo_endpoint": "https://auth.your-company.com/userinfo",
  "jwks_uri": "https://auth.your-company.com/.well-known/jwks.json"
}
{
  "issuer": "https://auth.your-company.com",
  "authorization_endpoint": "https://auth.your-company.com/authorize",
  "token_endpoint": "https://auth.your-company.com/token",
  "userinfo_endpoint": "https://auth.your-company.com/userinfo",
  "jwks_uri": "https://auth.your-company.com/.well-known/jwks.json"
}

ID Token

In addition to the access token, your token endpoint should return an ID token:

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

The ID token is a JWT containing identity claims:

json
{
  "iss": "https://auth.your-company.com",
  "sub": "user-unique-id",
  "aud": "storefront-client-id",
  "exp": 1735689600,
  "iat": 1735686000,
  "email": "user@example.com",
  "name": "John Doe"
}
{
  "iss": "https://auth.your-company.com",
  "sub": "user-unique-id",
  "aud": "storefront-client-id",
  "exp": 1735689600,
  "iat": 1735686000,
  "email": "user@example.com",
  "name": "John Doe"
}