ogmake

OG images in Rails

The fastest path for a Rails app is the signed GET /i/{keyId}/{sig}?...params URL: it renders on first request and serves from cache after that, so it can go straight into a layout's <meta property="og:image"> with no call to the ogmake API from your controller at request time.

1. Get a key

Sign in at /login and create a key in the dashboard. The signing secret is shown once — store it with bin/rails credentials:edit (e.g. under an ogmake.signing_secret key) rather than a plain ENV var, so it's encrypted at rest in config/credentials.yml.enc and read back with Rails.application.credentials.dig(:ogmake, :signing_secret).

Still on a hand-issued beta key? Email support@ogmake.com.

2. Sign the URL in Ruby

The canonical string: drop sig and debug, sort remaining keys by UTF-16 code unit (matches the server, not Ruby's default byte-order Array#sort), RFC3986-encode each key=value, join with &, then HMAC-SHA256 it with your key's signing secret. The file below is the real, interpreter-run script (docs/snippets/sign.rb, run with ruby sign.rb) also used on the signing page — it reproduces the same fixed test vector, so it is provably correct rather than just plausible. A view helper or presenter does the same two calls (canonical_query, sign_query) against your own params.

#!/usr/bin/env ruby
# docs/snippets/sign.rb — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
# Same fixed test vector as sign.mjs; run with `ruby sign.rb`, prints:
#   format=png&template=basic&title=Hello%20World
#   W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
require "openssl"
require "base64"
require "set"

# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = { "template" => "basic", "title" => "Hello World", "format" => "png" }.freeze

UNRESERVED = /[A-Za-z0-9\-_.~]/.freeze

def rfc3986_encode(value)
  value.to_s.b.each_byte.map do |byte|
    char = byte.chr
    UNRESERVED.match?(char) ? char : format("%%%02X", byte)
  end.join
end

# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = Set["sig", "debug"].freeze

# Sorts by UTF-16 code unit, the way JS's default string comparison sorts
# signing.ts's keys — NOT Ruby's `Array#sort`, which compares strings
# byte-by-byte (UTF-8 byte order). The two orders agree for BMP-only keys but
# diverge for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
# surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
# private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
# byte order puts it last. Using plain `.sort` here would build a different
# canonical string than the server and produce a bad signature for such keys.
def utf16_key(str)
  units = []
  str.each_codepoint do |cp|
    if cp > 0xFFFF
      cp -= 0x10000
      units << (0xD800 + (cp >> 10))
      units << (0xDC00 + (cp & 0x3FF))
    else
      units << cp
    end
  end
  units
end

def canonical_query(params)
  params.reject { |k, _| CANONICAL_EXCLUDED_KEYS.include?(k) }
    .sort_by { |k, _| utf16_key(k) }
    .map { |k, v| "#{rfc3986_encode(k)}=#{rfc3986_encode(v)}" }.join("&")
end

def base64url_encode(bytes)
  Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end

def sign_query(secret_base64, canonical)
  secret = Base64.strict_decode64(secret_base64)
  mac = OpenSSL::HMAC.digest("SHA256", secret, canonical)
  base64url_encode(mac)
end

canonical = canonical_query(PARAMS)
puts canonical
puts sign_query(SIGNING_SECRET_BASE64, canonical)

3. Put it in the layout

Compute the signed URL in a helper and pull it into app/views/layouts/application.html.erb:

# app/helpers/og_image_helper.rb
module OgImageHelper
  def og_image_url_for(post)
    canonical = canonical_query(
      "template" => "blog",
      "site" => "example.com/blog",
      "author" => post.author.name,
      "date" => post.published_at.strftime("%b %-d, %Y"),
      "title" => post.title,
    )
    secret = Rails.application.credentials.dig(:ogmake, :signing_secret)
    sig = sign_query(secret, canonical)
    key_id = Rails.application.credentials.dig(:ogmake, :key_id)
    "https://ogmake.com/i/#{key_id}/#{sig}?#{canonical}"
  end
end
<%# app/views/layouts/application.html.erb %>
<% if @post %>
  <meta property="og:image" content="<%= og_image_url_for(@post) %>">
  <meta name="twitter:card" content="summary_large_image">
<% end %>

Gotcha: Rails' Content Security Policy blocks the image by default

Rails 6+ ships a CSP initializer (config/initializers/content_security_policy.rb) that's commented out by default but, once enabled, defaults to same-origin for img-src. If your app has turned that on, the browser will silently refuse to load an ogmake.com image — the tag renders, the network request never completes, and there's no console error pointing at CSP unless you have report-only mode wired up. Add the host explicitly:

policy.img_src :self, :https, "ogmake.com"

Turbo/Hotwire's async page loads don't change this — the CSP applies to the rendered response either way, so this is a one-time config fix, not something to special-case per view.

Verify it

Load the page and confirm the tag itself first:

curl -s https://your-app.com/posts/some-post | grep 'og:image'

Then check the image URL resolves without an error header — a failed signed render is still a 200 fallback image, so curl -I the URL and look for x-ogmake-error (see the signing page's debugging section). Finally, run the real production URL through Facebook's Sharing Debugger or LinkedIn's Post Inspector, and compose a new post on X with the URL to check the card there (X retired its public Card Validator in 2022) — social crawlers cache a URL's preview, so to force a fresh scrape, include a v cache-busting param (e.g. a version string) in the params you sign and re-run sign_query. v is itself part of the signed canonical string, so it has to go through canonical_query like any other field — appending it to an already-signed URL just breaks the signature.