I Built a Free Devotional for Families

We wanted to read the Bible together as a family, but preparing for it was often enough to keep it from happening. On a normal weeknight, I needed to choose a passage, come up with questions, and be ready to talk through the answers. By the time I had thought about all of that, it felt like another task to fit into an already full evening.

I have written software for close to thirty years, so I decided to build something that would help. I wanted to be able to sit down with my family and have the reading and questions ready. That became My Family Devotion, a free site for families to read aloud together.

Each devotion includes:

  • The full Bible passage.
  • A short devotional reading to share aloud.
  • Four discussion questions with suggested answers.
  • A practical application.
  • A closing prayer.

The suggested answers are there to help the person leading the conversation. You can read them when you need them, and there is room for your family to talk through the passage in its own words.

The site currently has 2,335 devotions across all 66 books of the Bible. You can choose a book and work through its passages in order, including the ones that rarely turn up in a collection of favorite verses. I wanted the devotions to help families spend time with those passages too.

Some devotions include a short section called How We Know. It looks at evidence or background relevant to the passage, such as an ancient text, a custom, or an archaeological find. That part reflects my interest in apologetics. I include a note where there is useful evidence to discuss, with sources for claims from outside the Bible so readers can check them.

I use AI to help with drafting and research. I check each Scripture passage against the Berean Standard Bible, verify outside claims, and review and edit the writing before publication. The responsibility for what appears there is mine. If you find a mistake, please let me know.

The devotions focus on reading and discussing Scripture together. They reflect my Christian convictions, but I have kept debates about baptism, church government, and end-times interpretations out of them. Families can discuss those questions within their own church traditions.

There is no account to create, and every devotion is free to read. It will stay that way. If you would like to try it with your family, the getting started guide explains how to choose a reading and work through it together.

C# Export to Excel: Keep Your Dates, Numbers, and Leading Zeros

An Excel export can open without an error and still be wrong. An identifier loses its leading zeros. A date sorts as text. A long reference number comes back with different digits at the end. The download succeeded, but the person using it has a problem.

I wrote about exporting MySafeInfo data to Excel in C# back in 2016. That example used ASP.NET Web Forms and EPPlus. For this one, I wanted a complete, current example with explicit decisions about what goes into each cell.

The source is the States dataset from MySafeInfo, the reference-data service I built. It is available in full without an account or API key, so you can run this example as written. The workbook uses ClosedXML, an MIT-licensed library that creates real .xlsx files without requiring Excel to be installed.

The important choices are small enough to explain before the code:

  • Identifiers are text. The source ID identifies a record; nobody needs to add the IDs together. Names, abbreviations, and capitals are text too.
  • The source's Statehood value is a numeric year. It stays a number. Turning 1819 into January 1, 1819 would invent information the API did not provide.
  • The retrieval timestamp is an Excel date value with a display format. Its label explicitly says UTC, because the spreadsheet cell does not retain a time-zone offset.
  • An empty capital becomes a blank cell. A zero in the source year remains zero. Those are different values, and the exporter should not quietly decide they mean the same thing.

At the time of testing, the States endpoint returned 56 records, including territories and Washington, D.C. The code exports the records it receives; it does not assume this is a list of exactly 50 states or reinterpret the source's year values.

This is a .NET 10 console application using ClosedXML 0.105.1, the version tested here. Create the project and add the package:

dotnet new console --name MySafeInfoExcel --framework net10.0
cd MySafeInfoExcel
dotnet add package ClosedXML --version 0.105.1

Replace Program.cs with the following. It fetches the data, validates the fields, and writes two worksheets: the records themselves and a short record of where and when the data was retrieved.

using System.Globalization;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClosedXML.Excel;

try
{
    if (args.Length > 1)
        throw new ArgumentException("Usage: dotnet run -- [output.xlsx]");

    // One client for this one-shot console application.
    using var http = new HttpClient(new HttpClientHandler
    {
        AllowAutoRedirect = false
    })
    {
        Timeout = TimeSpan.FromSeconds(30),
        MaxResponseContentBufferSize = 1_048_576
    };
    http.DefaultRequestHeaders.Accept.ParseAdd("application/json");
    http.DefaultRequestHeaders.UserAgent.ParseAdd("MySafeInfoExcelExample/1.0");

    // Optional: States is available in full without a key.
    string? key = Environment.GetEnvironmentVariable("MYSAFEINFO_API_KEY");
    if (!string.IsNullOrWhiteSpace(key))
        http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", key.Trim());

    string path = Path.GetFullPath(args.Length == 1 ? args[0] : "states.xlsx");
    int count = await StateExport.RunAsync(http, path);
    Console.WriteLine($"Wrote {count} records to {path}");
    return 0;
}
catch (Exception ex) when (ex is HttpRequestException or JsonException
    or IOException or UnauthorizedAccessException or ArgumentException
    or InvalidDataException or FormatException or OperationCanceledException)
{
    Console.Error.WriteLine($"Export failed: {ex.Message}");
    return 1;
}

public static class StateExport
{
    public const string SourceUrl =
        "https://mysafeinfo.com/api/data/states?format=json&full=true";
    public const int MaxRows = 10_000;

    public static async Task<int> RunAsync(HttpClient http, string outputPath,
        CancellationToken cancellationToken = default)
    {
        string path = Path.GetFullPath(outputPath);
        if (!string.Equals(Path.GetExtension(path), ".xlsx",
            StringComparison.OrdinalIgnoreCase))
            throw new ArgumentException("The output file must end in .xlsx.");
        if (File.Exists(path))
            throw new IOException("The output file already exists. Choose a new name.");

        // Buffering keeps the client's response-size limit in effect.
        using var response = await http.GetAsync(SourceUrl,
            HttpCompletionOption.ResponseContentRead, cancellationToken);
        response.EnsureSuccessStatusCode();

        await using var json = await response.Content.ReadAsStreamAsync(cancellationToken);
        var rows = await JsonSerializer.DeserializeAsync<List<StateRow>>(
            json, new JsonSerializerOptions { RespectNullableAnnotations = true },
            cancellationToken) ?? throw new InvalidDataException("The API returned null.");
        Validate(rows);
        DateTimeOffset retrievedUtc = DateTimeOffset.UtcNow;

        // Finish the workbook before giving it its final name.
        string temporary = Path.Combine(Path.GetDirectoryName(path)!,
            $".{Guid.NewGuid():N}.tmp");
        try
        {
            using (var file = new FileStream(temporary, FileMode.CreateNew,
                FileAccess.ReadWrite, FileShare.None))
            {
                WriteWorkbook(rows, file, retrievedUtc);
            }
            cancellationToken.ThrowIfCancellationRequested();
            File.Move(temporary, path); // Does not overwrite an existing file.
        }
        finally
        {
            if (File.Exists(temporary))
                File.Delete(temporary);
        }
        return rows.Count;
    }

    public static void WriteWorkbook(IReadOnlyList<StateRow> rows,
        Stream output, DateTimeOffset retrievedUtc)
    {
        Validate(rows);
        using var workbook = new XLWorkbook();
        var sheet = workbook.Worksheets.Add("States");
        string[] headers = ["Source ID", "State / territory", "Abbreviation",
            "Capital", "Statehood (source year)"];
        for (int column = 0; column < headers.Length; column++)
            sheet.Cell(1, column + 1).Value = headers[column];

        for (int index = 0; index < rows.Count; index++)
        {
            var item = rows[index];
            int row = index + 2;
            sheet.Cell(row, 1).Value = item.Id.ToString(CultureInfo.InvariantCulture);
            // Rich text preserves literal leading apostrophes in source strings.
            sheet.Cell(row, 2).GetRichText().AddText(item.StateName);
            sheet.Cell(row, 3).GetRichText().AddText(item.Abbreviation);
            if (item.Capital.Length > 0)
                sheet.Cell(row, 4).GetRichText().AddText(item.Capital);
            sheet.Cell(row, 5).Value = item.Statehood;
        }

        sheet.Range(2, 1, rows.Count + 1, 1).Style.NumberFormat.Format = "@";
        sheet.Range(2, 5, rows.Count + 1, 5).Style.NumberFormat.Format = "0";
        sheet.Range(1, 1, rows.Count + 1, 5).CreateTable("StatesTable");
        sheet.SheetView.FreezeRows(1);
        sheet.Column(1).Width = 12;
        sheet.Column(2).Width = 30;
        sheet.Column(3).Width = 16;
        sheet.Column(4).Width = 26;
        sheet.Column(5).Width = 26;

        var info = workbook.Worksheets.Add("Export info");
        info.Cell("A1").Value = "Source";
        info.Cell("B1").Value = SourceUrl;
        info.Cell("A2").Value = "Retrieved (UTC)";
        info.Cell("B2").Value = retrievedUtc.UtcDateTime;
        info.Cell("B2").Style.NumberFormat.Format = "yyyy-mm-dd hh:mm:ss";
        info.Cell("A3").Value = "Records";
        info.Cell("B3").Value = rows.Count;
        info.Cell("A4").Value = "Scope";
        info.Cell("B4").Value = "Includes territories and Washington, D.C.";
        info.Cell("A5").Value = "Year values";
        info.Cell("B5").Value = "Copied from the source, including zero; not full dates.";
        info.Column(1).Width = 22;
        info.Column(2).Width = 80;
        info.Range("A1:A5").Style.Font.Bold = true;
        workbook.SaveAs(output);
    }

    private static void Validate(IReadOnlyList<StateRow> rows)
    {
        if (rows.Count is 0 or > MaxRows)
            throw new InvalidDataException($"Expected 1 to {MaxRows} records.");

        var ids = new HashSet<int>();
        foreach (var item in rows)
        {
            if (item is null || item.Id <= 0 || !ids.Add(item.Id)
                || string.IsNullOrWhiteSpace(item.StateName)
                || string.IsNullOrWhiteSpace(item.Abbreviation)
                || item.Capital is null || item.Statehood is < 0 or > 9999
                || item.StateName.Length > 32_767
                || item.Abbreviation.Length > 32_767
                || item.Capital.Length > 32_767)
                throw new InvalidDataException("Unexpected States data; no export was saved.");
        }
    }
}

public sealed record StateRow
{
    [JsonPropertyName("ID")]
    public required int Id { get; init; }
    public required string StateName { get; init; }
    public required string Abbreviation { get; init; }
    public required string Capital { get; init; }
    public required int Statehood { get; init; }
}

Run it with an output filename:

dotnet run -- states.xlsx

The destination directory must already exist. If the file exists, the program refuses to overwrite it; use another filename. A failed request or invalid response produces an error and a nonzero exit code. The final filename is created only after the workbook has been saved successfully.

No key is needed for this dataset. If you have a pass, the optional MYSAFEINFO_API_KEY environment variable sends your key in the authorization header. Keep it out of source code and URLs. The request also uses full=true, which asks MySafeInfo to fail rather than silently return a sample when full access requires a key. Other datasets have different fields and need their own model and column mapping; changing the URL alone is not enough. The API documentation covers access and request limits.

The States IDs do not contain leading zeros. To see why the text decision matters elsewhere, this small, separate workbook uses a postal code and a long reference number:

using ClosedXML.Excel;

using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("Text examples");
sheet.Cell("A1").Value = "Postal code";
sheet.Cell("A2").Value = "00501";
sheet.Cell("B1").Value = "Reference";
sheet.Cell("B2").Value = "123456789012345678";
sheet.Range("A2:B2").Style.NumberFormat.Format = "@";
workbook.SaveAs("text-examples.xlsx");

Run that separately, with an unused filename. Both values are assigned as strings. The text format makes the intent explicit, but formatting is not what preserves the original characters. If the application already converted 00501 to the number 501, changing the cell's format cannot recover what was lost. Excel also has a 15-digit limit on numeric precision, so long identifiers belong in text cells.

Dates work the other way around. The export assigns a DateTime to the timestamp cell, then gives it the format yyyy-mm-dd hh:mm:ss. It does not call ToString() and hand Excel a date-shaped piece of text. As the ClosedXML formatting documentation explains, a number format changes presentation, not the underlying value.

Testing caught another detail worth keeping: assigning a string through Value can treat a leading apostrophe as Excel's text prefix. The main export uses GetRichText().AddText() for the API's text fields so a literal apostrophe survives. The tests also check that strings beginning with =, +, -, and @ remain text. Imported text never gets assigned to FormulaA1.

The verification went beyond checking that a file appeared. The final example was run against the live API without a key, the saved workbook was reopened, and its structure was checked with the Open XML SDK validator. Automated cases covered:

  • Text and numeric cell types, leading zeros, an 18-digit reference, Unicode, literal apostrophes, and formula-like text.
  • UTC timestamp conversion and results under U.S., German, and French culture settings.
  • Missing fields, nulls, duplicate IDs, invalid JSON, oversized responses, and too many records.
  • HTTP errors, including 429, cancellation, timeouts, and protecting an existing destination file, including one created during the request.

This is deliberately a bounded export. The HTTP response is capped at one MiB, the request times out after 30 seconds, and validation rejects more than 10,000 rows. Those are example limits, not promises about how much memory a workbook will use. ClosedXML builds the workbook in memory. For a large export or a web application serving concurrent downloads, measure the workload and choose an appropriate background-job or streaming design. Reuse or manage HttpClient through the application's normal lifetime management. The single client here lives for the duration of this console run.

The example also stops on a rate-limit response instead of repeatedly calling the API. A scheduled integration needs a retry policy that respects Retry-After, along with whatever monitoring the job requires.

The next time you check an export, try doing something with it. Sort the dates. Filter a numeric column. Read an identifier back and compare it with the source. Those checks tell you much more than whether Excel managed to open the file.

I Was Asked for Three Books. I Couldn't Do It.

Someone asked me recently for the three books, besides the Bible, that taught me the most about defending my faith. I could not do it. Every time I got the list down to three, I had to cut one that changed how I think, so I stopped narrowing and kept them all.

  • Mere Christianity by C.S. Lewis is where I would start with someone exploring Christianity. Lewis begins with ordinary human experience, including our sense of right and wrong, and works toward the Christian understanding of God and how we should live.

  • More Than a Carpenter by Josh and Sean McDowell is a shorter introduction, focused on Jesus and the evidence for his identity and resurrection. It is small enough to read in an afternoon and hand to a friend.

  • I Don't Have Enough Faith to Be an Atheist by Norman Geisler and Frank Turek makes a broader case, working through questions about truth, God's existence, and the evidence for Christianity. I value the way it develops the argument in steps, rather than treating each objection as an isolated question.

  • The Case for Christ by Lee Strobel approaches the evidence through interviews with scholars. Strobel asks about the reliability of the Gospel accounts and the case for the resurrection, then gives the scholars room to explain their answers.

  • Cold-Case Christianity by J. Warner Wallace examines the Gospel accounts using the methods he brought to his work as a cold-case homicide detective. Questions about witnesses, corroboration, and the handling of evidence give it a different approach from the other books here.

  • Tactics by Greg Koukl is the most practical book on this list for having a conversation about faith. It helps with asking questions, understanding what someone means, and examining a claim without turning the discussion into a fight. Knowing an argument does not necessarily mean knowing how to bring it into a conversation.

  • Love Your God with All Your Mind by J.P. Moreland makes the case that developing the mind is part of Christian discipleship. Reasoning carefully about what I believe belongs in my life as a Christian, including when no one is asking me to defend it.

  • The Story of Reality by Greg Koukl presents Christianity as an account of the world, from its beginning through its final resolution. It helps explain how the beliefs fit together, which can get lost when a conversation moves from one objection to the next.

  • Seven Days That Divide the World by John Lennox addresses Genesis and science, including different interpretations of the creation account. Science comes up more than any other subject when people find out I take Christianity seriously, and I appreciate Lennox's care with both the biblical text and the scientific questions.

  • Can Science Explain Everything? by John Lennox is a shorter book on the relationship between science and Christian belief. It examines what science can explain and whether those explanations leave any reason to believe in God. I would suggest this one for someone who wants to begin with that question rather than the interpretation of Genesis.

If I had to give someone just three to begin with, I would choose Mere Christianity for an introduction to the faith, I Don't Have Enough Faith to Be an Atheist for a fuller examination of the arguments, and Tactics for someone who already believes but goes quiet whenever the subject comes up.

Thirty Years of Code, Five Lessons I Keep Using

I started writing software in the mid-1990s. I've worked through changes in languages, frameworks, and database versions ever since. Looking back, five lessons stand out in the way I work now. Some took much longer to learn than others.

  1. Know what actually runs. When I write a query, I want to see its execution plan and understand which indexes it uses. If it is slow, I want to investigate the statement the database received. That is a large part of why I still use Dapper. I'm comfortable working directly in SQL, and I want to be able to follow a query from the application into the database when I need to diagnose a problem.

  2. The data outlives the code. This took me longer to fully respect. I recently moved an application first written in 2013 onto current .NET, and the production database under it did not change at all. The schema I had designed years earlier was still doing its job after the original application was replaced. Database design deserves care on its own terms, because the application being built today may be only one of several that eventually use it.

  3. Test the way the application actually runs. Before changing a piece of authentication logic recently, I ran a read-only check across thousands of real accounts, connecting the way the real application connects. The connection path was part of what I needed to verify. A simplified test could exercise the logic and still miss a problem caused by how the application accessed those accounts. For that change, I needed evidence from the conditions it would actually encounter.

  4. Understanding the business often takes more work than writing the code. Early on, I expected the difficult part to be the implementation. Over time, I became more concerned with understanding what the business needed and keeping those rules clear in the application. When a rule is scattered across database logic and screen behavior, even a small change can require looking in several places to understand what it will affect. I want to be able to find the rule, explain it, and change it without having to rediscover how the business works.

  5. Keep reviewing my own decisions. During a redesign of My New Password, I found a security bug in the password generator I had written years earlier. I used the site frequently myself, and the passwords looked as random as I expected. It took looking at the implementation again to see the problem. I want to give code I wrote years ago the same attention I would give a change I was reviewing today.

One Verse I Keep Coming Back To

Paul says he learned to be content. That word, learned, is the one I keep coming back to. I find it encouraging that he describes something he had to learn, because contentment can seem like a quality some people simply have and others don't.

He wrote the letter while imprisoned. In the first chapter, he considers both the possibility of death and the work he could continue if he lived. He wants to be with Christ, but he also wants to remain and help the people he is writing to.

And notice it runs both directions. Paul says he knows how to live with little, and he knows how to live with plenty (Philippians 4:12). I tend to think of contentment as something needed when life is hard. Having enough sounds as though it ought to settle the question. But it is possible to have plenty and still be dissatisfied, or to become so comfortable that dependence on God gets little thought.

That is the setting for a verse that often gets separated from the sentences around it:

I can do all things through him who strengthens me. (Philippians 4:13, ESV)

It is easy to read that as encouragement to accomplish whatever we set our minds to. Read it after Paul's description of hunger and plenty, though, and the claim is more specific. He depends on Christ for the strength to live through either. His confidence does not depend on getting the outcome he wants. That makes the verse useful even when a situation fails to improve.

Paul also thanks the Philippians for helping him. Immediately after verse 13, he acknowledges their kindness in sharing his trouble, and a few verses later he says their gifts have supplied his needs. He can be content and still appreciate relief. There is no need to pretend hunger is harmless or that another person's help makes no difference.

I keep coming back to this passage because most days I am nearer the plenty than the prison, and that is exactly when I forget. I can read it as encouragement for someone going through a hard time and miss how much it has to do with me. Paul includes plenty among the circumstances in which he relies on Christ. I need that dependence when life is comfortable too.

Qualys 150300: HTTP Request Smuggling on Azure App Service

Qualys recently started reporting 150300: HTTP Request Smuggling against a .NET web application I work on in Azure App Service. Earlier scans had not flagged it. We tested two configuration changes independently, and either one cleared the finding in our tests: an IIS request-filtering rule in web.config, or enabling HTTP/2 in App Service. I ultimately standardized on HTTP/2.

The changes were small, but understanding what they did mattered. A newly reported finding does not establish when the underlying condition began. Qualys has had this QID since 2020. Without comparing the scan evidence and configuration history, I would not attribute its appearance to a particular scanner or platform update.

HTTP request smuggling happens when components handling a request disagree about where it ends. A reverse proxy might treat some bytes as part of one request while the server behind it treats those same bytes as the beginning of another. Depending on the system, that disagreement can let an attacker bypass request checks or interfere with another user's traffic.

In the CL:TE test Qualys documents for QID 150300, the front end follows Content-Length, while the back end follows Transfer-Encoding. The former specifies the body length in bytes; Transfer-Encoding: chunked describes a body sent in chunks. The problem is inconsistent handling of request boundaries across the connection.

Here are the two approaches we tested, and the tradeoffs I would consider before using either one elsewhere.

  1. The first approach used IIS request filtering in web.config. This is the relevant configuration, consolidated into one requestFiltering element:

    <configuration>
      <system.webServer>
        <security>
          <requestFiltering removeServerHeader="true">
            <requestLimits>
              <headerLimits>
                <add header="Transfer-Encoding" sizeLimit="0" />
              </headerLimits>
            </requestLimits>
            <verbs>
              <add verb="TRACE" allowed="false" />
            </verbs>
          </requestFiltering>
        </security>
      </system.webServer>
    </configuration>
    

    The setting relevant to request framing is sizeLimit="0" on Transfer-Encoding. Microsoft's header-limit documentation says that a zero limit effectively denies requests containing that header. It does not remove the header and let the request continue.

    The other two settings do different jobs. removeServerHeader="true" suppresses IIS's server response header, and the verbs entry denies TRACE requests. Those are separate IIS filtering settings; neither resolves disagreement over request lengths. Merge these entries into the existing configuration rather than replacing the whole file or adding a second requestFiltering element. Update any existing matching collection entries instead of duplicating them.

    The advantage is an explicit rule that can live alongside the application in source control. The cost is that it also rejects legitimate requests using chunked transfer encoding if they reach IIS with that header. Uploads and streaming clients need particular attention. This is an IIS-specific restriction, and it acts where IIS applies the filter; it does not establish that every upstream proxy handles requests correctly.

  2. The second approach was enabling HTTP/2 in Azure App Service. This independently cleared the finding in our tests and was the option I chose to standardize on. Microsoft's configuration documentation places the HTTP version setting under Configuration, General settings. Set it to 2.0 and apply the change.

    There are good reasons to enable it beyond this finding. HTTP/2 allows multiple requests to share a connection concurrently and compresses headers to reduce overhead. PSRule for Azure recommends considering HTTP/2 for App Service protocol efficiency. It is a supported platform capability that does not require rewriting the application's handlers.

    The advantage here was a simple platform setting, without adding a blanket ban on the Transfer-Encoding header. Enabling support also leaves HTTP/1.1 available for clients that use it. For ordinary HTTPS applications, that makes HTTP/2 a reasonable default to test and adopt. Keep HTTPS enabled and verify the application's actual clients and integrations; HTTP/2 support and HTTPS enforcement are separate settings.

    The limitation is important: enabling HTTP/2 does not require every connection to use it. Microsoft's App Service protocol documentation distinguishes enabling HTTP/2 at the front end from configuring HTTP/2 proxying to the application. The version setting alone is not proof of HTTP/2 from the client through to the back end.

That distinction matters for request smuggling. HTTP/2 uses explicit framing, but a proxy can translate an incoming HTTP/2 request into HTTP/1.1 before forwarding it. PortSwigger documents how that translation can introduce request-smuggling vulnerabilities. Qualys's recommendation to use HTTP/2 for back-end connections is therefore more specific than turning on HTTP/2 support for clients.

Our A/B tests established that both changes independently resolved the reported finding in our environment. That is a useful result. To carry the same conclusion into another environment, I would check:

  • Retest QID 150300 against the same endpoint with comparable scan settings, and inspect the evidence rather than relying only on its absence from a summary.
  • Verify the negotiated protocol and test any remaining HTTP/1.1 path. A browser showing HTTP/2 does not tell you which protocol a proxy uses behind it.
  • Exercise normal application traffic, especially uploads, streaming requests, authentication, and integrations. Include the CDN, WAF, or other proxies that are actually in the request path.

I chose HTTP/2 because it resolved this finding in our tests and gave us a useful platform standard without the additional IIS restriction. I would make that setting part of deployment configuration so it is reproduced consistently, and keep the scan evidence with the change. That records both what we changed and what we verified.

What Billy Graham Taught Me About the Cross

I have listened to Billy Graham and read his books for a long time, especially over the past decade. His autobiography, Just As I Am, is one of my favorites. One story from it has stayed with me: the night a friend told him he had left the cross out of his sermon.

It happened in Dallas in 1953. Graham recalled feeling that his preaching that evening had lacked spiritual depth, even though people had responded to the invitation. Afterward, he took a walk with his friend John Bolten, a German-born businessman. Bolten told him what was missing.

“Billy,” he said, “you didn’t speak about the Cross.”

In the account from Just As I Am, Bolten pressed him to explain Christ's death to the people he was asking to believe. Graham spent a sleepless night thinking about that conversation. By morning, he had resolved to make the gospel clear in his preaching, with Christ's death for our sins and His resurrection at its center.

He also told the story in a 1959 address to ministers. There, he recalled praying:

“Oh, God, so help me, there will never be a sermon that I preach unless the Cross is central.”

I admire his willingness to receive that correction. He took it seriously enough to change his preaching and to include the failure in his own autobiography. Reading it years later, I am grateful that he did. It gives the rest of us something more useful than an account of a successful evening.

It is possible to say many true things about Christianity and still leave someone unclear about why Jesus died. We can talk about living well, finding purpose, or having hope. We can defend the reliability of Scripture and answer objections to belief. But the person listening still needs to hear what God has done for sinners, and why we need Him to do it.

That part of the story really hits home for me. I care about apologetics. I read the arguments, think about the objections, and want to give honest answers. Those questions deserve attention. I also want anyone who reads what I write about faith to understand what I am inviting them to believe. Christianity rests on Jesus Christ, who died for our sins and rose from the dead. He calls us to turn from sin and trust Him for forgiveness. We cannot earn it by becoming better at defending Christianity, or by becoming impressive Christians ourselves.

In 1 Corinthians 2:2 (ESV), Paul put his purpose plainly: “For I decided to know nothing among you except Jesus Christ and him crucified.” Later in the same letter, he reminded the Corinthians of Christ's death for their sins, His burial, and His resurrection (1 Corinthians 15:3-4). That was the good news Graham's friend was urging him to make clear.

For me, there is comfort in that as well as a responsibility. The message does not depend on my having Billy Graham's voice or his ability to speak to a crowd. I can explain what Christ has done, listen to someone's questions, and trust God with what I cannot accomplish in another person's heart.

I am thankful for Billy Graham and for the friend who spoke honestly to him that night. The reason the story stays with me is that I need the same Savior he preached. Whatever opportunity I have to speak about my faith, I want to make clear why my hope is in Christ.